package downcloud import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strings" "time" ) type APIError struct { StatusCode int Message string } func (e *APIError) Error() string { return fmt.Sprintf("API error: %d", e.StatusCode) } func isAuthError(err error) bool { var apiErr *APIError if errors.As(err, &apiErr) { return apiErr.StatusCode == http.StatusUnauthorized || apiErr.StatusCode == http.StatusForbidden } return false } func doResolveURL(ctx context.Context, targetURL, clientID, oauthToken string) ([]byte, error) { reqURL := fmt.Sprintf("https://api-v2.soundcloud.com/resolve?url=%s&client_id=%s", url.QueryEscape(targetURL), clientID) req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) if err != nil { return nil, err } req.Header.Set("User-Agent", UserAgent) if oauthToken != "" { req.Header.Set("Authorization", "OAuth "+oauthToken) } client := &http.Client{Timeout: 30 * time.Second} res, err := client.Do(req) if err != nil { return nil, err } defer res.Body.Close() if res.StatusCode != http.StatusOK { return nil, &APIError{StatusCode: res.StatusCode} } return io.ReadAll(res.Body) } func ResolveURL(ctx context.Context, targetURL, clientID, oauthToken string) ([]byte, error) { data, err := doResolveURL(ctx, targetURL, clientID, oauthToken) if err != nil && isAuthError(err) { if newCID, refErr := RefreshClientID(); refErr == nil && newCID != "" { return doResolveURL(ctx, targetURL, newCID, oauthToken) } } return data, err } func doFetchFullTrack(ctx context.Context, trackID int64, clientID, oauthToken string) (*Track, error) { reqURL := fmt.Sprintf("https://api-v2.soundcloud.com/tracks/%d?client_id=%s", trackID, clientID) req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) if err != nil { return nil, err } req.Header.Set("User-Agent", UserAgent) if oauthToken != "" { req.Header.Set("Authorization", "OAuth "+oauthToken) } client := &http.Client{Timeout: 30 * time.Second} res, err := client.Do(req) if err != nil { return nil, err } defer res.Body.Close() if res.StatusCode != http.StatusOK { return nil, &APIError{StatusCode: res.StatusCode} } var track Track if err := json.NewDecoder(res.Body).Decode(&track); err != nil { return nil, err } return &track, nil } func FetchFullTrack(ctx context.Context, trackID int64, clientID, oauthToken string) (*Track, error) { track, err := doFetchFullTrack(ctx, trackID, clientID, oauthToken) if err != nil && isAuthError(err) { if newCID, refErr := RefreshClientID(); refErr == nil && newCID != "" { return doFetchFullTrack(ctx, trackID, newCID, oauthToken) } } return track, err } type trackCollectionResponse struct { Collection []Track `json:"collection"` NextHref *string `json:"next_href"` } func FetchArtistTracks(ctx context.Context, artistURL, clientID, oauthToken string) (*ArtistData, error) { raw, err := ResolveURL(ctx, artistURL, clientID, oauthToken) if err != nil { return nil, err } var user User if err := json.Unmarshal(raw, &user); err != nil { return nil, fmt.Errorf("failed to parse user info: %w", err) } tracks, err := FetchUserTracks(ctx, user.ID, clientID, oauthToken) if err != nil { return nil, err } return &ArtistData{ User: user, Tracks: tracks, }, nil } func FetchUserTracks(ctx context.Context, userID int64, clientID, oauthToken string) ([]Track, error) { var tracks []Track nextURL := fmt.Sprintf("https://api-v2.soundcloud.com/users/%d/tracks?client_id=%s&limit=50", userID, clientID) client := &http.Client{Timeout: 30 * time.Second} currentCID := clientID for nextURL != "" { req, err := http.NewRequestWithContext(ctx, http.MethodGet, nextURL, nil) if err != nil { return nil, err } req.Header.Set("User-Agent", UserAgent) if oauthToken != "" { req.Header.Set("Authorization", "OAuth "+oauthToken) } res, err := client.Do(req) if err != nil { return nil, err } if res.StatusCode == http.StatusUnauthorized || res.StatusCode == http.StatusForbidden { res.Body.Close() if newCID, refErr := RefreshClientID(); refErr == nil && newCID != "" { currentCID = newCID parsed, parseErr := url.Parse(nextURL) if parseErr == nil { q := parsed.Query() q.Set("client_id", currentCID) parsed.RawQuery = q.Encode() nextURL = parsed.String() } req, err = http.NewRequestWithContext(ctx, http.MethodGet, nextURL, nil) if err != nil { return nil, err } req.Header.Set("User-Agent", UserAgent) if oauthToken != "" { req.Header.Set("Authorization", "OAuth "+oauthToken) } res, err = client.Do(req) if err != nil { return nil, err } } } if res.StatusCode != http.StatusOK { res.Body.Close() return nil, &APIError{StatusCode: res.StatusCode} } var data trackCollectionResponse err = json.NewDecoder(res.Body).Decode(&data) res.Body.Close() if err != nil { return nil, err } tracks = append(tracks, data.Collection...) if data.NextHref != nil && *data.NextHref != "" { parsed, err := url.Parse(*data.NextHref) if err != nil { break } q := parsed.Query() if q.Get("client_id") == "" { q.Set("client_id", currentCID) parsed.RawQuery = q.Encode() } nextURL = parsed.String() } else { nextURL = "" } if len(data.Collection) == 0 { break } } return tracks, nil } type ResolvedKind string const ( KindTrack ResolvedKind = "track" KindPlaylist ResolvedKind = "playlist" KindArtist ResolvedKind = "artist" KindUnknown ResolvedKind = "unknown" ) func DetectKind(raw []byte) ResolvedKind { var m map[string]json.RawMessage if err := json.Unmarshal(raw, &m); err != nil { return KindUnknown } if _, ok := m["media"]; ok { return KindTrack } if _, ok := m["tracks"]; ok { return KindPlaylist } if _, ok := m["username"]; ok { if _, hasPermalink := m["permalink"]; hasPermalink { return KindArtist } } var kindStruct struct { Kind string `json:"kind"` } if err := json.Unmarshal(raw, &kindStruct); err == nil { switch strings.ToLower(kindStruct.Kind) { case "track": return KindTrack case "playlist": return KindPlaylist case "user": return KindArtist } } return KindUnknown }