mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-05-20 15:15:33 +02:00
864 lines
23 KiB
Go
864 lines
23 KiB
Go
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"
|
|
deezerPlaylistURL = deezerBaseURL + "/playlist/%s"
|
|
|
|
deezerCacheTTL = 10 * time.Minute
|
|
|
|
deezerMaxParallelISRC = 10
|
|
)
|
|
|
|
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
|
|
}
|
|
|
|
var (
|
|
deezerClient *DeezerClient
|
|
deezerClientOnce sync.Once
|
|
)
|
|
|
|
func GetDeezerClient() *DeezerClient {
|
|
deezerClientOnce.Do(func() {
|
|
deezerClient = &DeezerClient{
|
|
httpClient: NewHTTPClientWithTimeout(15 * time.Second),
|
|
searchCache: make(map[string]*cacheEntry),
|
|
albumCache: make(map[string]*cacheEntry),
|
|
artistCache: make(map[string]*cacheEntry),
|
|
isrcCache: make(map[string]string),
|
|
}
|
|
})
|
|
return deezerClient
|
|
}
|
|
|
|
type deezerTrack struct {
|
|
ID int64 `json:"id"`
|
|
Title string `json:"title"`
|
|
Duration int `json:"duration"` // in seconds
|
|
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, // Added this
|
|
TrackNumber: track.TrackPosition,
|
|
DiscNumber: track.DiskNumber,
|
|
ExternalURL: track.Link,
|
|
ISRC: track.ISRC,
|
|
}
|
|
}
|
|
|
|
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"`
|
|
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"`
|
|
}
|
|
|
|
// NOTE: ISRC is NOT fetched during search for performance - use GetTrackISRC when needed for download
|
|
func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) {
|
|
GoLog("[Deezer] SearchAll: query=%q, trackLimit=%d, artistLimit=%d\n", query, trackLimit, artistLimit)
|
|
|
|
cacheKey := fmt.Sprintf("deezer:all:%s:%d:%d", query, trackLimit, artistLimit)
|
|
|
|
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),
|
|
}
|
|
|
|
// Search tracks - NO ISRC fetch for performance
|
|
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))
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
GoLog("[Deezer] SearchAll complete: %d tracks, %d artists\n", len(result.Tracks), len(result.Artists))
|
|
|
|
c.cacheMu.Lock()
|
|
c.searchCache[cacheKey] = &cacheEntry{
|
|
data: result,
|
|
expiresAt: time.Now().Add(deezerCacheTTL),
|
|
}
|
|
c.cacheMu.Unlock()
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// GetTrack fetches a single track by Deezer ID
|
|
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
|
|
}
|
|
|
|
// ISRC is fetched in parallel for better performance
|
|
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, ", ")
|
|
}
|
|
|
|
// Extract genres as comma-separated string
|
|
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, // From Deezer album
|
|
Label: album.Label, // From Deezer album
|
|
}
|
|
|
|
isrcMap := c.fetchISRCsParallel(ctx, album.Tracks.Data)
|
|
|
|
tracks := make([]AlbumTrackMetadata, 0, len(album.Tracks.Data))
|
|
// Normalize record_type (Deezer uses "compile" instead of "compilation")
|
|
albumType := album.RecordType
|
|
if albumType == "compile" {
|
|
albumType = "compilation"
|
|
}
|
|
|
|
for i, track := range album.Tracks.Data {
|
|
trackIDStr := fmt.Sprintf("%d", track.ID)
|
|
isrc := isrcMap[trackIDStr]
|
|
|
|
// Use track position from API, fallback to index+1 if not provided
|
|
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()
|
|
c.albumCache[albumID] = &cacheEntry{
|
|
data: result,
|
|
expiresAt: time.Now().Add(deezerCacheTTL),
|
|
}
|
|
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()
|
|
|
|
// Fetch artist info
|
|
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,
|
|
}
|
|
|
|
// Fetch artist albums
|
|
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"` // album, single, ep, compile
|
|
} `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()
|
|
c.artistCache[artistID] = &cacheEntry{
|
|
data: result,
|
|
expiresAt: time.Now().Add(deezerCacheTTL),
|
|
}
|
|
c.cacheMu.Unlock()
|
|
|
|
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
|
|
|
|
isrcMap := c.fetchISRCsParallel(ctx, playlist.Tracks.Data)
|
|
|
|
tracks := make([]AlbumTrackMetadata, 0, len(playlist.Tracks.Data))
|
|
for _, track := range playlist.Tracks.Data {
|
|
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
|
|
}
|
|
|
|
// fetchISRCsParallel fetches ISRCs for multiple tracks in parallel with caching
|
|
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.cacheMu.Unlock()
|
|
}
|
|
|
|
if len(tracksToFetch) == 0 {
|
|
return result
|
|
}
|
|
|
|
// Use semaphore to limit concurrent requests
|
|
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
|
|
}
|
|
|
|
// Store in result and cache
|
|
resultMu.Lock()
|
|
result[trackIDStr] = fullTrack.ISRC
|
|
resultMu.Unlock()
|
|
|
|
c.cacheMu.Lock()
|
|
c.isrcCache[trackIDStr] = fullTrack.ISRC
|
|
c.cacheMu.Unlock()
|
|
}(track)
|
|
}
|
|
|
|
wg.Wait()
|
|
return result
|
|
}
|
|
|
|
// Use this when you need ISRC for download
|
|
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.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 // Comma-separated list of genres
|
|
Label string // Record label name
|
|
}
|
|
|
|
// Uses the album ID from a track to fetch extended metadata
|
|
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,
|
|
}
|
|
|
|
c.cacheMu.Lock()
|
|
c.searchCache[cacheKey] = &cacheEntry{
|
|
data: result,
|
|
expiresAt: time.Now().Add(deezerCacheTTL),
|
|
}
|
|
c.cacheMu.Unlock()
|
|
|
|
GoLog("[Deezer] Album metadata fetched - Genre: %s, Label: %s\n", result.Genre, result.Label)
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// GetTrackAlbumID fetches the album ID for a Deezer track
|
|
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
|
|
}
|
|
|
|
// This is a convenience function that first gets the album ID, then fetches album metadata
|
|
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)
|
|
}
|
|
|
|
// GetExtendedMetadataByISRC searches for a track by ISRC and fetches extended metadata (genre, label)
|
|
func (c *DeezerClient) GetExtendedMetadataByISRC(ctx context.Context, isrc string) (*AlbumExtendedMetadata, error) {
|
|
if isrc == "" {
|
|
return nil, fmt.Errorf("empty ISRC")
|
|
}
|
|
|
|
// First, search for track by ISRC
|
|
track, err := c.SearchByISRC(ctx, isrc)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to find track by ISRC: %w", err)
|
|
}
|
|
|
|
// SpotifyID contains "deezer:123" format, extract the ID
|
|
deezerID := track.SpotifyID
|
|
if strings.HasPrefix(deezerID, "deezer:") {
|
|
deezerID = strings.TrimPrefix(deezerID, "deezer:")
|
|
}
|
|
|
|
if deezerID == "" {
|
|
return nil, fmt.Errorf("track found but no Deezer ID")
|
|
}
|
|
|
|
// Then fetch extended metadata using the Deezer track ID
|
|
return c.GetExtendedMetadataByTrackID(ctx, deezerID)
|
|
}
|
|
|
|
func (c *DeezerClient) getJSON(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)
|
|
}
|
|
|
|
// parseDeezerURL is internal function, returns type and ID
|
|
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)
|
|
}
|
|
}
|