- Document all exported types, functions, methods, and constants across 52 files - Add package-level doc comments to client, models, utils, and ynison packages - Document HTTP method and endpoint path for each API client method - Document model struct fields and DeJSON/DeList/ToJSON/ToMap methods - Fix duplicate package doc comment in internal/models/base.go Co-Authored-By: Claude Code <noreply@anthropic.com>
203 lines
6.4 KiB
Go
203 lines
6.4 KiB
Go
package client
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.mrixs.me/Mrixs/yandex-music-api-go/internal/utils"
|
|
)
|
|
|
|
// DeviceCode represents the device authorization response from the Yandex OAuth device flow.
|
|
type DeviceCode struct {
|
|
DeviceCode string `json:"device_code"`
|
|
UserCode string `json:"user_code"`
|
|
VerificationURL string `json:"verification_url"`
|
|
ExpiresIn int `json:"expires_in"`
|
|
Interval int `json:"interval"`
|
|
}
|
|
|
|
// OAuthToken represents an OAuth token response from the Yandex OAuth token endpoint.
|
|
type OAuthToken struct {
|
|
AccessToken string `json:"access_token"`
|
|
RefreshToken string `json:"refresh_token"`
|
|
TokenType string `json:"token_type"`
|
|
ExpiresIn int `json:"expires_in"`
|
|
}
|
|
|
|
// DeviceAuth performs OAuth Device Flow authentication.
|
|
// It requests a device code, invokes the onCode callback with it, then polls for the token.
|
|
// The onCode callback receives the DeviceCode which contains the user_code and verification URL.
|
|
func (r *Request) DeviceAuth(ctx context.Context, onCode func(*DeviceCode)) (*OAuthToken, error) {
|
|
// Step 1: Request device code
|
|
deviceCode, err := r.requestDeviceCode(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Call callback with device code
|
|
if onCode != nil {
|
|
onCode(deviceCode)
|
|
}
|
|
|
|
// Step 2: Poll for token
|
|
return r.pollForToken(ctx, deviceCode)
|
|
}
|
|
|
|
// requestDeviceCode requests a device code from the Yandex OAuth device code endpoint.
|
|
func (r *Request) requestDeviceCode(ctx context.Context) (*DeviceCode, error) {
|
|
// Use Yandex OAuth endpoint for device auth
|
|
data := url.Values{}
|
|
data.Set("client_id", "23cabbbdc6cd418abb4b39c32c41195d")
|
|
data.Set("scope", "login:info login:email yandex_music")
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "POST", "https://oauth.yandex.ru/device/code", strings.NewReader(data.Encode()))
|
|
if err != nil {
|
|
return nil, utils.NewYandexMusicError("failed to create device code request", err)
|
|
}
|
|
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.Header.Set("User-Agent", UserAgent)
|
|
|
|
resp, err := r.client.Do(req)
|
|
if err != nil {
|
|
return nil, utils.NewYandexMusicError("failed to request device code", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, utils.NewYandexMusicError("failed to read device code response", err)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, utils.NewYandexMusicError(fmt.Sprintf("device code request failed: %s", string(body)), utils.ErrBadRequest)
|
|
}
|
|
|
|
var deviceCode DeviceCode
|
|
if err := json.Unmarshal(body, &deviceCode); err != nil {
|
|
return nil, utils.NewYandexMusicError("failed to parse device code", err)
|
|
}
|
|
|
|
return &deviceCode, nil
|
|
}
|
|
|
|
// pollForToken polls the Yandex OAuth token endpoint until the user authorizes the device code.
|
|
// It returns the OAuthToken once authorization is complete, or an error if the code expires or is denied.
|
|
func (r *Request) pollForToken(ctx context.Context, deviceCode *DeviceCode) (*OAuthToken, error) {
|
|
interval := deviceCode.Interval
|
|
if interval == 0 {
|
|
interval = 5
|
|
}
|
|
|
|
deadline := time.Now().Add(time.Duration(deviceCode.ExpiresIn) * time.Second)
|
|
ticker := time.NewTicker(time.Duration(interval) * time.Second)
|
|
defer ticker.Stop()
|
|
|
|
data := url.Values{}
|
|
data.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code")
|
|
data.Set("client_id", "23cabbbdc6cd418abb4b39c32c41195d")
|
|
data.Set("device_code", deviceCode.DeviceCode)
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
case <-ticker.C:
|
|
if time.Now().After(deadline) {
|
|
return nil, utils.NewYandexMusicError("device authorization timed out", utils.ErrTimeout)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "POST", "https://oauth.yandex.ru/token", strings.NewReader(data.Encode()))
|
|
if err != nil {
|
|
return nil, utils.NewYandexMusicError("failed to create token request", err)
|
|
}
|
|
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.Header.Set("User-Agent", UserAgent)
|
|
|
|
resp, err := r.client.Do(req)
|
|
if err != nil {
|
|
continue // Retry on network error
|
|
}
|
|
|
|
body, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
|
|
if resp.StatusCode == http.StatusOK {
|
|
var token OAuthToken
|
|
if err := json.Unmarshal(body, &token); err != nil {
|
|
return nil, utils.NewYandexMusicError("failed to parse token", err)
|
|
}
|
|
return &token, nil
|
|
}
|
|
|
|
// Check for specific errors
|
|
var errResp map[string]string
|
|
json.Unmarshal(body, &errResp)
|
|
if errResp["error"] == "authorization_pending" {
|
|
continue // Keep polling
|
|
}
|
|
if errResp["error"] == "slow_down" {
|
|
interval *= 2
|
|
ticker.Reset(time.Duration(interval) * time.Second)
|
|
continue
|
|
}
|
|
if errResp["error"] == "expired_token" {
|
|
return nil, utils.NewYandexMusicError("device code expired", utils.ErrTimeout)
|
|
}
|
|
if errResp["error"] == "access_denied" {
|
|
return nil, utils.NewYandexMusicError("access denied by user", utils.ErrUnauthorized)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// RefreshToken refreshes an access token using the provided refresh token.
|
|
// It makes a POST request to the Yandex OAuth token endpoint with grant_type=refresh_token.
|
|
func (r *Request) RefreshToken(ctx context.Context, refreshToken string) (*OAuthToken, error) {
|
|
data := url.Values{}
|
|
data.Set("grant_type", "refresh_token")
|
|
data.Set("client_id", "23cabbbdc6cd418abb4b39c32c41195d")
|
|
data.Set("refresh_token", refreshToken)
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "POST", "https://oauth.yandex.ru/token", strings.NewReader(data.Encode()))
|
|
if err != nil {
|
|
return nil, utils.NewYandexMusicError("failed to create refresh request", err)
|
|
}
|
|
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.Header.Set("User-Agent", UserAgent)
|
|
|
|
resp, err := r.client.Do(req)
|
|
if err != nil {
|
|
return nil, utils.NewYandexMusicError("failed to refresh token", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, utils.NewYandexMusicError("failed to read refresh response", err)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, utils.NewYandexMusicError(fmt.Sprintf("token refresh failed: %s", string(body)), utils.ErrUnauthorized)
|
|
}
|
|
|
|
var token OAuthToken
|
|
if err := json.Unmarshal(body, &token); err != nil {
|
|
return nil, utils.NewYandexMusicError("failed to parse refreshed token", err)
|
|
}
|
|
|
|
return &token, nil
|
|
}
|
|
|
|
// Missing imports - need to add at top of file
|
|
// "io"
|
|
// "net/http"
|