package client import ( "bytes" "context" "fmt" "io" "net/http" "net/url" "strings" "time" "gitea.mrixs.me/Mrixs/yandex-music-api-go/internal/utils" ) // Default values for the Yandex Music API client. const ( DefaultBaseURL = "https://api.music.yandex.net" DefaultTimeout = 5 * time.Second UserAgent = "Yandex-Music-API" DefaultClientHeader = "YandexMusicAndroid/24023621" ) // RequestConfig holds configuration for the request client. type RequestConfig struct { BaseURL string Token string ProxyURL string Timeout time.Duration Headers map[string]string JSONBackend utils.JSONBackend Language string Device string ReportUnknownFields bool Strict bool OnSchemaMismatch utils.SchemaMismatchHandler } // Request handles HTTP requests to the Yandex Music API. // It manages the HTTP client, authentication headers, and request signing. type Request struct { config RequestConfig client *http.Client schemaTracker *utils.SchemaMismatchTracker } // NewRequest creates a new Request instance with the given configuration. // It sets up the HTTP client, default headers, and authentication if a token is provided. func NewRequest(config RequestConfig) *Request { if config.BaseURL == "" { config.BaseURL = DefaultBaseURL } if config.Timeout == 0 { config.Timeout = DefaultTimeout } if config.Headers == nil { config.Headers = make(map[string]string) } if config.JSONBackend == nil { config.JSONBackend = utils.GetDefaultJSONBackend() } // Set default headers if _, ok := config.Headers["X-Yandex-Music-Client"]; !ok { config.Headers["X-Yandex-Music-Client"] = DefaultClientHeader } // Configure HTTP client httpClient := &http.Client{ Timeout: config.Timeout, } // Configure proxy if provided if config.ProxyURL != "" { proxyURL, err := url.Parse(config.ProxyURL) if err == nil { httpClient.Transport = &http.Transport{ Proxy: http.ProxyURL(proxyURL), } } } r := &Request{ config: config, client: httpClient, schemaTracker: utils.NewSchemaMismatchTracker(), } // Set token if provided if config.Token != "" { r.SetToken(config.Token) } return r } // SetToken updates the authorization token used for API requests. // When a token is provided, it sets the Authorization header to "OAuth ". func (r *Request) SetToken(token string) { r.config.Token = token if token != "" { r.config.Headers["Authorization"] = "OAuth " + token } else { delete(r.config.Headers, "Authorization") } } // SetLanguage sets the Accept-Language header for API requests. func (r *Request) SetLanguage(lang string) { r.config.Language = lang r.config.Headers["Accept-Language"] = lang } // Get performs a GET request to the given path with query parameters. // The response is unmarshaled into the result pointer. func (r *Request) Get(ctx context.Context, path string, params map[string]string, result any) error { return r.doRequest(ctx, "GET", path, params, nil, result) } // Post performs a POST request to the given path with a JSON body. // The response is unmarshaled into the result pointer. func (r *Request) Post(ctx context.Context, path string, body any, result any) error { return r.doRequest(ctx, "POST", path, nil, body, result) } // Put performs a PUT request to the given path with a JSON body. // The response is unmarshaled into the result pointer. func (r *Request) Put(ctx context.Context, path string, body any, result any) error { return r.doRequest(ctx, "PUT", path, nil, body, result) } // Delete performs a DELETE request to the given path with an optional JSON body. // The response is unmarshaled into the result pointer. func (r *Request) Delete(ctx context.Context, path string, body any, result any) error { return r.doRequest(ctx, "DELETE", path, nil, body, result) } // doRequest performs the actual HTTP request to the Yandex Music API. // It handles URL building, request signing, response parsing, and error handling. func (r *Request) doRequest(ctx context.Context, method, path string, params map[string]string, body any, result any) error { // Build URL fullURL := r.config.BaseURL + path if len(params) > 0 { q := url.Values{} for k, v := range params { q.Set(k, v) } fullURL += "?" + q.Encode() } // Prepare request body var bodyReader io.Reader if body != nil { bodyBytes, err := r.config.JSONBackend.Marshal(body) if err != nil { return utils.NewYandexMusicError("failed to marshal request body", err) } bodyReader = bytes.NewReader(bodyBytes) } // Create HTTP request req, err := http.NewRequestWithContext(ctx, method, fullURL, bodyReader) if err != nil { return utils.NewYandexMusicError("failed to create request", err) } // Set headers req.Header.Set("User-Agent", UserAgent) for k, v := range r.config.Headers { req.Header.Set(k, v) } if body != nil { req.Header.Set("Content-Type", "application/json") } // Execute request resp, err := r.client.Do(req) if err != nil { if strings.Contains(err.Error(), "timeout") { return utils.NewYandexMusicError("request timeout", utils.ErrTimeout) } return utils.NewYandexMusicError("network error", utils.ErrNetwork) } defer resp.Body.Close() // Read response respBody, err := io.ReadAll(resp.Body) if err != nil { return utils.NewYandexMusicError("failed to read response", err) } // Handle error status codes if resp.StatusCode < 200 || resp.StatusCode >= 300 { return r.handleErrorResponse(resp.StatusCode, respBody) } // Parse response if result != nil { apiResp, err := utils.DeJSON(respBody) if err != nil { return utils.NewYandexMusicError("failed to parse response", err) } if apiResp.HasError() { return utils.NewYandexMusicError(apiResp.GetError(), utils.ErrBadRequest) } // Unmarshal result into target if apiResp.Result != nil { if err := r.config.JSONBackend.Unmarshal(apiResp.Result, result); err != nil { return utils.NewYandexMusicError("failed to unmarshal result", err) } } } return nil } // handleErrorResponse handles HTTP error responses by mapping status codes to typed errors. func (r *Request) handleErrorResponse(statusCode int, body []byte) error { var msg string apiResp, err := utils.DeJSON(body) if err == nil && apiResp != nil { msg = apiResp.GetError() } else { msg = string(body) } switch statusCode { case http.StatusUnauthorized, http.StatusForbidden: return utils.NewYandexMusicError(msg, utils.ErrUnauthorized) case http.StatusBadRequest: return utils.NewYandexMusicError(msg, utils.ErrBadRequest) case http.StatusNotFound: return utils.NewYandexMusicError(msg, utils.ErrNotFound) case http.StatusConflict, http.StatusRequestEntityTooLarge: return utils.NewYandexMusicError(msg, utils.ErrNetwork) case http.StatusBadGateway: return utils.NewYandexMusicError("Bad Gateway", utils.ErrNetwork) default: return utils.NewYandexMusicError(fmt.Sprintf("%s (%d): %s", msg, statusCode, string(body)), utils.ErrNetwork) } } // Download downloads a file from the given URL and returns the raw response body. // It is used for downloading track files and lyrics text. func (r *Request) Download(ctx context.Context, urlStr string) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, "GET", urlStr, nil) if err != nil { return nil, utils.NewYandexMusicError("failed to create download request", err) } req.Header.Set("User-Agent", UserAgent) for k, v := range r.config.Headers { req.Header.Set(k, v) } resp, err := r.client.Do(req) if err != nil { return nil, utils.NewYandexMusicError("download failed", err) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { body, _ := io.ReadAll(resp.Body) return nil, r.handleErrorResponse(resp.StatusCode, body) } return io.ReadAll(resp.Body) }