diff --git a/go_backend/deezer.go b/go_backend/deezer.go index 94f4566a..668699cf 100644 --- a/go_backend/deezer.go +++ b/go_backend/deezer.go @@ -14,12 +14,12 @@ import ( ) const ( - deezerBaseURL = "https://api.deezer.com/2.0" - deezerSearchURL = deezerBaseURL + "/search" - deezerTrackURL = deezerBaseURL + "/track/%s" - deezerAlbumURL = deezerBaseURL + "/album/%s" - deezerArtistURL = deezerBaseURL + "/artist/%s" - deezerPlaylistURL = deezerBaseURL + "/playlist/%s" + deezerBaseURL = "https://api.deezer.com/2.0" + deezerSearchURL = deezerBaseURL + "/search" + deezerTrackURL = deezerBaseURL + "/track/%s" + deezerAlbumURL = deezerBaseURL + "/album/%s" + deezerArtistURL = deezerBaseURL + "/artist/%s" + deezerPlaylistURL = deezerBaseURL + "/playlist/%s" deezerCacheTTL = 10 * time.Minute @@ -67,250 +67,6 @@ func GetDeezerClient() *DeezerClient { 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"` - ExplicitLyrics bool `json:"explicit_lyrics"` - ExplicitContentLyrics int `json:"explicit_content_lyrics"` - Artist deezerArtist `json:"artist"` - Album deezerAlbumSimple `json:"album"` - Contributors []deezerArtist `json:"contributors"` -} - -// deezerTrackIsExplicit maps Deezer's parental-advisory fields to a boolean: -// explicit_lyrics is the boolean flag, explicit_content_lyrics uses 1 to mean -// explicit (0 = clean, 2 = unknown). -func deezerTrackIsExplicit(track deezerTrack) bool { - return track.ExplicitLyrics || track.ExplicitContentLyrics == 1 -} - -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"` -} - -// deezerTrackArtistDisplay returns the display artist string for a track, -// preferring the Contributors list (comma-joined) when available, falling -// back to the primary Artist.Name. -func deezerTrackArtistDisplay(track deezerTrack) string { - if len(track.Contributors) > 0 { - names := make([]string, len(track.Contributors)) - for i, a := range track.Contributors { - names[i] = a.Name - } - return strings.Join(names, ", ") - } - return track.Artist.Name -} - -func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata { - artistName := deezerTrackArtistDisplay(track) - - 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), - Explicit: deezerTrackIsExplicit(track), - } -} - -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) @@ -562,383 +318,6 @@ func (c *DeezerClient) GetTrack(ctx context.Context, trackID string) (*TrackResp }, 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) - totalDiscs := 0 - for _, track := range allTracks { - if track.DiskNumber > totalDiscs { - totalDiscs = track.DiskNumber - } - } - - 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: deezerTrackArtistDisplay(track), - 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, - TotalDiscs: totalDiscs, - ExternalURL: track.Link, - ISRC: isrc, - AlbumID: fmt.Sprintf("deezer:%d", album.ID), - AlbumType: albumType, - Explicit: deezerTrackIsExplicit(track), - }) - } - - 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, - }) - } - - // The Deezer /artist/{id}/albums endpoint does not return nb_tracks. - // Fetch track counts in parallel from individual /album/{id} endpoints. - c.fetchAlbumTrackCounts(ctx, albums) - } - - 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 -} - -// fetchAlbumTrackCounts fetches nb_tracks for each album in parallel using -// individual /album/{id} calls, since the /artist/{id}/albums endpoint does -// not include this field. Albums whose track count is already known (non-zero) -// are skipped. -func (c *DeezerClient) fetchAlbumTrackCounts(ctx context.Context, albums []ArtistAlbumMetadata) { - type indexedID struct { - idx int - albumID string - } - var toFetch []indexedID - for i, a := range albums { - if a.TotalTracks == 0 { - rawID := strings.TrimPrefix(a.ID, "deezer:") - if rawID != "" { - toFetch = append(toFetch, indexedID{idx: i, albumID: rawID}) - } - } - } - if len(toFetch) == 0 { - return - } - - const maxParallel = 10 - sem := make(chan struct{}, maxParallel) - var mu sync.Mutex - var wg sync.WaitGroup - - for _, item := range toFetch { - wg.Add(1) - go func(it indexedID) { - defer wg.Done() - - select { - case sem <- struct{}{}: - defer func() { <-sem }() - case <-ctx.Done(): - return - } - - albumURL := fmt.Sprintf(deezerAlbumURL, it.albumID) - var resp struct { - NbTracks int `json:"nb_tracks"` - } - if err := c.getJSON(ctx, albumURL, &resp); err != nil { - return - } - - mu.Lock() - albums[it.idx].TotalTracks = resp.NbTracks - mu.Unlock() - }(item) - } - - wg.Wait() -} - -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: deezerTrackArtistDisplay(track), - 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), - Explicit: deezerTrackIsExplicit(track), - }) - } - - 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) @@ -1071,45 +450,6 @@ func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string 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 diff --git a/go_backend/deezer_browse.go b/go_backend/deezer_browse.go new file mode 100644 index 00000000..e7b05049 --- /dev/null +++ b/go_backend/deezer_browse.go @@ -0,0 +1,386 @@ +package gobackend + +import ( + "context" + "fmt" + "strings" + "sync" + "time" +) + +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) + totalDiscs := 0 + for _, track := range allTracks { + if track.DiskNumber > totalDiscs { + totalDiscs = track.DiskNumber + } + } + + 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: deezerTrackArtistDisplay(track), + 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, + TotalDiscs: totalDiscs, + ExternalURL: track.Link, + ISRC: isrc, + AlbumID: fmt.Sprintf("deezer:%d", album.ID), + AlbumType: albumType, + Explicit: deezerTrackIsExplicit(track), + }) + } + + 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, + }) + } + + // The Deezer /artist/{id}/albums endpoint does not return nb_tracks. + // Fetch track counts in parallel from individual /album/{id} endpoints. + c.fetchAlbumTrackCounts(ctx, albums) + } + + 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 +} + +// fetchAlbumTrackCounts fetches nb_tracks for each album in parallel using +// individual /album/{id} calls, since the /artist/{id}/albums endpoint does +// not include this field. Albums whose track count is already known (non-zero) +// are skipped. +func (c *DeezerClient) fetchAlbumTrackCounts(ctx context.Context, albums []ArtistAlbumMetadata) { + type indexedID struct { + idx int + albumID string + } + var toFetch []indexedID + for i, a := range albums { + if a.TotalTracks == 0 { + rawID := strings.TrimPrefix(a.ID, "deezer:") + if rawID != "" { + toFetch = append(toFetch, indexedID{idx: i, albumID: rawID}) + } + } + } + if len(toFetch) == 0 { + return + } + + const maxParallel = 10 + sem := make(chan struct{}, maxParallel) + var mu sync.Mutex + var wg sync.WaitGroup + + for _, item := range toFetch { + wg.Add(1) + go func(it indexedID) { + defer wg.Done() + + select { + case sem <- struct{}{}: + defer func() { <-sem }() + case <-ctx.Done(): + return + } + + albumURL := fmt.Sprintf(deezerAlbumURL, it.albumID) + var resp struct { + NbTracks int `json:"nb_tracks"` + } + if err := c.getJSON(ctx, albumURL, &resp); err != nil { + return + } + + mu.Lock() + albums[it.idx].TotalTracks = resp.NbTracks + mu.Unlock() + }(item) + } + + wg.Wait() +} + +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: deezerTrackArtistDisplay(track), + 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), + Explicit: deezerTrackIsExplicit(track), + }) + } + + return &PlaylistResponsePayload{ + PlaylistInfo: info, + TrackList: tracks, + }, nil +} diff --git a/go_backend/deezer_cache.go b/go_backend/deezer_cache.go new file mode 100644 index 00000000..9e60278b --- /dev/null +++ b/go_backend/deezer_cache.go @@ -0,0 +1,97 @@ +package gobackend + +import "time" + +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) + } +} diff --git a/go_backend/deezer_models.go b/go_backend/deezer_models.go new file mode 100644 index 00000000..b3c2ea9f --- /dev/null +++ b/go_backend/deezer_models.go @@ -0,0 +1,195 @@ +package gobackend + +import ( + "fmt" + "strings" +) + +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"` + ExplicitLyrics bool `json:"explicit_lyrics"` + ExplicitContentLyrics int `json:"explicit_content_lyrics"` + Artist deezerArtist `json:"artist"` + Album deezerAlbumSimple `json:"album"` + Contributors []deezerArtist `json:"contributors"` +} + +// deezerTrackIsExplicit maps Deezer's parental-advisory fields to a boolean: +// explicit_lyrics is the boolean flag, explicit_content_lyrics uses 1 to mean +// explicit (0 = clean, 2 = unknown). +func deezerTrackIsExplicit(track deezerTrack) bool { + return track.ExplicitLyrics || track.ExplicitContentLyrics == 1 +} + +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"` +} + +// deezerTrackArtistDisplay returns the display artist string for a track, +// preferring the Contributors list (comma-joined) when available, falling +// back to the primary Artist.Name. +func deezerTrackArtistDisplay(track deezerTrack) string { + if len(track.Contributors) > 0 { + names := make([]string, len(track.Contributors)) + for i, a := range track.Contributors { + names[i] = a.Name + } + return strings.Join(names, ", ") + } + return track.Artist.Name +} + +func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata { + artistName := deezerTrackArtistDisplay(track) + + 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), + Explicit: deezerTrackIsExplicit(track), + } +} + +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) 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 +}