mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-03-31 00:39:24 +02:00
1273 lines
34 KiB
Go
1273 lines
34 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"
|
|
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"`
|
|
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
|
|
}
|
|
|
|
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()
|
|
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\n", result.Genre, result.Label)
|
|
|
|
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)) // Exponential backoff
|
|
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()
|
|
|
|
// Check if error is retryable
|
|
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)
|
|
}
|
|
}
|