Initial commit

This commit is contained in:
Mrixs committed 2026-10-14 23:04:51 +03:00
commit 02a6dabcfb
62 files changed
+15751

No files matched your search

+126
View File
@@ -0,0 +1,126 @@
package utils
import (
"encoding/json"
"reflect"
"strings"
)
// JSONBackend defines the interface for JSON encoding/decoding
type JSONBackend interface {
Marshal(v any) ([]byte, error)
Unmarshal(data []byte, v any) error
}
// DefaultJSONBackend uses the standard library encoding/json
type DefaultJSONBackend struct{}
func (DefaultJSONBackend) Marshal(v any) ([]byte, error) {
return json.Marshal(v)
}
func (DefaultJSONBackend) Unmarshal(data []byte, v any) error {
return json.Unmarshal(data, v)
}
// GetDefaultJSONBackend returns the default JSON backend
func GetDefaultJSONBackend() JSONBackend {
return DefaultJSONBackend{}
}
// RawMessage is a raw encoded JSON value
type RawMessage = json.RawMessage
// Marshal is a convenience function
func Marshal(v any) ([]byte, error) {
return GetDefaultJSONBackend().Marshal(v)
}
// Unmarshal is a convenience function
func Unmarshal(data []byte, v any) error {
return GetDefaultJSONBackend().Unmarshal(data, v)
}
// ToSnakeCase converts camelCase to snake_case
func ToSnakeCase(s string) string {
if s == "" {
return s
}
var result strings.Builder
for i, r := range s {
if r >= 'A' && r <= 'Z' {
if i > 0 {
result.WriteRune('_')
}
result.WriteRune(r + 'a' - 'A')
} else {
result.WriteRune(r)
}
}
return result.String()
}
// ToCamelCase converts snake_case to camelCase
func ToCamelCase(s string) string {
if s == "" {
return s
}
parts := strings.Split(s, "_")
if len(parts) == 1 {
return s
}
var result strings.Builder
result.WriteString(parts[0])
for _, part := range parts[1:] {
if len(part) > 0 {
result.WriteString(strings.ToUpper(part[:1]) + part[1:])
}
}
return result.String()
}
// MapKeysToSnakeCase converts all keys in a map to snake_case
func MapKeysToSnakeCase(m map[string]any) map[string]any {
result := make(map[string]any, len(m))
for k, v := range m {
result[ToSnakeCase(k)] = v
}
return result
}
// MapKeysToCamelCase converts all keys in a map to camelCase
func MapKeysToCamelCase(m map[string]any) map[string]any {
result := make(map[string]any, len(m))
for k, v := range m {
result[ToCamelCase(k)] = v
}
return result
}
// StructToMap converts a struct to a map using json tags
func StructToMap(v any, forRequest bool) (map[string]any, error) {
data, err := Marshal(v)
if err != nil {
return nil, err
}
var result map[string]any
if err := Unmarshal(data, &result); err != nil {
return nil, err
}
return result, nil
}
// GetJSONTag returns the json tag for a struct field
func GetJSONTag(t reflect.Type, fieldIndex int) string {
if fieldIndex >= t.NumField() {
return ""
}
field := t.Field(fieldIndex)
tag := field.Tag.Get("json")
if tag == "" || tag == "-" {
return ""
}
// Parse tag options (e.g., "name,omitempty")
parts := strings.Split(tag, ",")
return parts[0]
}
+140
View File
@@ -0,0 +1,140 @@
package utils
import (
"encoding/json"
"errors"
)
// Response represents an API response
type Response struct {
Result json.RawMessage `json:"result"`
Error string `json:"error,omitempty"`
ErrorDescription string `json:"errorDescription,omitempty"`
}
// GetResult returns the result as raw JSON
func (r *Response) GetResult() json.RawMessage {
return r.Result
}
// GetError returns the error message
func (r *Response) GetError() string {
if r.Error != "" {
return r.Error
}
if r.ErrorDescription != "" {
return r.ErrorDescription
}
return "Unknown error"
}
// HasError returns true if the response contains an error
func (r *Response) HasError() bool {
return r.Error != "" || r.ErrorDescription != ""
}
// UnmarshalJSON implements custom unmarshaling
func (r *Response) UnmarshalJSON(data []byte) error {
// First try to unmarshal as a standard response
type Alias Response
aux := &struct {
*Alias
}{
Alias: (*Alias)(r),
}
if err := json.Unmarshal(data, aux); err != nil {
return err
}
// If result is missing, wrap the whole response in result
if r.Result == nil {
r.Result = data
}
return nil
}
// DeJSON creates a Response from JSON data
func DeJSON(data []byte) (*Response, error) {
var resp Response
if err := json.Unmarshal(data, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// Error types
var (
ErrUnauthorized = errors.New("unauthorized")
ErrBadRequest = errors.New("bad request")
ErrNotFound = errors.New("not found")
ErrNetwork = errors.New("network error")
ErrTimeout = errors.New("timeout")
ErrSchemaMismatch = errors.New("schema mismatch")
)
// YandexMusicError is the base error type
type YandexMusicError struct {
Message string
Err error
}
func (e *YandexMusicError) Error() string {
if e.Err != nil {
return e.Message + ": " + e.Err.Error()
}
return e.Message
}
func (e *YandexMusicError) Unwrap() error {
return e.Err
}
// NewYandexMusicError creates a new YandexMusicError
func NewYandexMusicError(message string, err error) *YandexMusicError {
return &YandexMusicError{Message: message, Err: err}
}
// IsUnauthorizedError checks if error is unauthorized
func IsUnauthorizedError(err error) bool {
var yme *YandexMusicError
if errors.As(err, &yme) {
return errors.Is(yme.Err, ErrUnauthorized)
}
return errors.Is(err, ErrUnauthorized)
}
// IsBadRequestError checks if error is bad request
func IsBadRequestError(err error) bool {
var yme *YandexMusicError
if errors.As(err, &yme) {
return errors.Is(yme.Err, ErrBadRequest)
}
return errors.Is(err, ErrBadRequest)
}
// IsNotFoundError checks if error is not found
func IsNotFoundError(err error) bool {
var yme *YandexMusicError
if errors.As(err, &yme) {
return errors.Is(yme.Err, ErrNotFound)
}
return errors.Is(err, ErrNotFound)
}
// IsNetworkError checks if error is network error
func IsNetworkError(err error) bool {
var yme *YandexMusicError
if errors.As(err, &yme) {
return errors.Is(yme.Err, ErrNetwork)
}
return errors.Is(err, ErrNetwork)
}
// IsTimeoutError checks if error is timeout
func IsTimeoutError(err error) bool {
var yme *YandexMusicError
if errors.As(err, &yme) {
return errors.Is(yme.Err, ErrTimeout)
}
return errors.Is(err, ErrTimeout)
}
+103
View File
@@ -0,0 +1,103 @@
package utils
import (
"fmt"
"log"
"strings"
"sync"
)
// SchemaMismatch represents a mismatch between expected and actual API response
type SchemaMismatch struct {
ModelName string
MissingFields []string
UnknownFields []string
UnknownValues map[string]any
Endpoint string
LibraryVersion string
}
func (sm *SchemaMismatch) Describe() string {
var parts []string
parts = append(parts, fmt.Sprintf("Model: %s", sm.ModelName))
if len(sm.MissingFields) > 0 {
parts = append(parts, fmt.Sprintf("Missing fields: %v", sm.MissingFields))
}
if len(sm.UnknownFields) > 0 {
parts = append(parts, fmt.Sprintf("Unknown fields: %v", sm.UnknownFields))
}
if len(sm.UnknownValues) > 0 {
parts = append(parts, fmt.Sprintf("Unknown field structures: %v", sm.UnknownValues))
}
parts = append(parts, fmt.Sprintf("Endpoint: %s", sm.Endpoint))
parts = append(parts, fmt.Sprintf("Library version: %s", sm.LibraryVersion))
return strings.Join(parts, "\n")
}
// SchemaMismatchHandler is a callback for handling schema mismatches
type SchemaMismatchHandler func(*SchemaMismatch)
// SchemaMismatchConfig holds configuration for schema mismatch handling
type SchemaMismatchConfig struct {
ReportUnknownFields bool
Strict bool
OnSchemaMismatch SchemaMismatchHandler
}
// DefaultSchemaMismatchConfig returns the default configuration
func DefaultSchemaMismatchConfig() SchemaMismatchConfig {
return SchemaMismatchConfig{
ReportUnknownFields: false,
Strict: false,
OnSchemaMismatch: nil,
}
}
// ReportSchemaMismatch reports a schema mismatch according to config
func ReportSchemaMismatch(config SchemaMismatchConfig, modelName, endpoint string, missingFields, unknownFields []string, unknownValues map[string]any) error {
mismatch := &SchemaMismatch{
ModelName: modelName,
MissingFields: missingFields,
UnknownFields: unknownFields,
UnknownValues: unknownValues,
Endpoint: endpoint,
LibraryVersion: "go-port-0.1.0",
}
if config.OnSchemaMismatch != nil {
config.OnSchemaMismatch(mismatch)
return nil
}
msg := mismatch.Describe()
if config.Strict {
return fmt.Errorf("schema mismatch: %s", msg)
}
log.Printf("WARNING: Schema mismatch for %s:\n%s", modelName, msg)
return nil
}
// SchemaMismatchTracker tracks reported mismatches to avoid duplicates
type SchemaMismatchTracker struct {
mu sync.Mutex
reported map[string]bool
}
func NewSchemaMismatchTracker() *SchemaMismatchTracker {
return &SchemaMismatchTracker{
reported: make(map[string]bool),
}
}
func (t *SchemaMismatchTracker) ShouldReport(modelName string, fields []string) bool {
t.mu.Lock()
defer t.mu.Unlock()
key := modelName + ":" + strings.Join(fields, ",")
if t.reported[key] {
return false
}
t.reported[key] = true
return true
}
+75
View File
@@ -0,0 +1,75 @@
package utils
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"fmt"
"strconv"
"time"
)
const (
// DefaultSignKey is the signing key from the Android app
DefaultSignKey = "p93jhgh689SBReK6ghtw62"
// DesktopSignKey is the signing key from the desktop app
DesktopSignKey = "kzqU4XhfCaY6B6JTHODeq5"
// DesktopClient is the X-Yandex-Music-Client header value for desktop
DesktopClient = "YandexMusicDesktopAppWindows/5.95.0"
)
// Sign represents a request signature
type Sign struct {
Timestamp int64
Value string
}
// GetSignRequest creates a signature for a track request
func GetSignRequest(trackID string, key string) Sign {
if key == "" {
key = DefaultSignKey
}
timestamp := time.Now().Unix()
message := trackID + strconv.FormatInt(timestamp, 10)
h := hmac.New(sha256.New, []byte(key))
h.Write([]byte(message))
sign := base64.StdEncoding.EncodeToString(h.Sum(nil))
return Sign{
Timestamp: timestamp,
Value: sign,
}
}
// GetFileInfoSign creates a signature for file info requests
func GetFileInfoSign(trackID, quality string, codecs []string, transport, key string) Sign {
if key == "" {
key = DesktopSignKey
}
timestamp := time.Now().Unix()
message := fmt.Sprintf("%d%s%s%s%s", timestamp, trackID, quality, joinCodecs(codecs), transport)
h := hmac.New(sha256.New, []byte(key))
h.Write([]byte(message))
sign := base64.StdEncoding.EncodeToString(h.Sum(nil))
// Remove last character as in Python
if len(sign) > 0 {
sign = sign[:len(sign)-1]
}
return Sign{
Timestamp: timestamp,
Value: sign,
}
}
func joinCodecs(codecs []string) string {
result := ""
for _, c := range codecs {
result += c
}
return result
}
+233
View File
@@ -0,0 +1,233 @@
package utils
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"strconv"
"testing"
"time"
)
// TestGetSignRequest tests the GetSignRequest function with various inputs
func TestGetSignRequest(t *testing.T) {
// Test with default key
sign := GetSignRequest("12345", "")
if sign.Timestamp == 0 {
t.Error("Expected non-zero timestamp")
}
if sign.Value == "" {
t.Error("Expected non-empty sign value")
}
// Test with custom key
sign = GetSignRequest("12345", "custom-key")
if sign.Timestamp == 0 {
t.Error("Expected non-zero timestamp")
}
if sign.Value == "" {
t.Error("Expected non-empty sign value")
}
// Test track ID as string (function accepts string)
sign = GetSignRequest("12345", "")
if sign.Timestamp == 0 {
t.Error("Expected non-zero timestamp for string track ID")
}
if sign.Value == "" {
t.Error("Expected non-empty sign value for string track ID")
}
}
// TestGetFileInfoSign tests the GetFileInfoSign function with various inputs
func TestGetFileInfoSign(t *testing.T) {
codecs := []string{"mp3", "flac"}
sign := GetFileInfoSign("12345", "high", codecs, "http", "")
if sign.Timestamp == 0 {
t.Error("Expected non-zero timestamp")
}
if sign.Value == "" {
t.Error("Expected non-empty sign value")
}
// Test with custom key
sign = GetFileInfoSign("12345", "high", codecs, "http", "custom-key")
if sign.Timestamp == 0 {
t.Error("Expected non-zero timestamp")
}
if sign.Value == "" {
t.Error("Expected non-empty sign value")
}
// Test empty codecs
sign = GetFileInfoSign("12345", "high", []string{}, "http", "")
if sign.Timestamp == 0 {
t.Error("Expected non-zero timestamp for empty codecs")
}
if sign.Value == "" {
t.Error("Expected non-empty sign value for empty codecs")
}
}
// TestSignDeterministic tests that same input produces same output with fixed time
func TestSignDeterministic(t *testing.T) {
// We can't easily test this without mocking time, but we can verify
// that the function doesn't panic
for i := 0; i < 10; i++ {
sign := GetSignRequest("test-track", "test-key")
if sign.Value == "" {
t.Errorf("Iteration %d: got empty sign", i)
}
}
}
// TestGetSignRequestFixedTimestamp tests GetSignRequest with a fixed timestamp
// to verify deterministic output matches Python reference implementation
func TestGetSignRequestFixedTimestamp(t *testing.T) {
// Use a fixed timestamp for deterministic testing
fixedTime := time.Unix(1668687184, 0)
trackID := "4784420"
key := "SUPER_SECRET_KEY"
expectedSign := "vssEEweZhgv2Aud0rdH9maOXUC03ZkZ/hlo6bSRN8Qg="
// Manually compute expected sign with fixed time
message := trackID + strconv.FormatInt(fixedTime.Unix(), 10)
h := hmac.New(sha256.New, []byte(key))
h.Write([]byte(message))
computedSign := base64.StdEncoding.EncodeToString(h.Sum(nil))
if computedSign != expectedSign {
t.Errorf("Expected sign %q, got %q", expectedSign, computedSign)
}
// Test that our function produces correct sign when we override time
// Note: We can't easily mock time.Now() in Go without refactoring,
// so we test the algorithm directly here
sign := GetSignRequest(trackID, key)
_ = sign // Just verify no panic
}
// TestGetFileInfoSignFixedTimestamp tests GetFileInfoSign with a fixed timestamp
func TestGetFileInfoSignFixedTimestamp(t *testing.T) {
fixedTime := time.Unix(1668687184, 0)
trackID := "4784420:100500"
quality := "lossless"
codecs := []string{"flac", "aac", "mp3"}
transport := "encraw"
key := "SUPER_SECRET_KEY"
// Manually compute expected sign with fixed time (matching Python)
message := strconv.FormatInt(fixedTime.Unix(), 10) + trackID + quality + "flacaacmp3" + transport
h := hmac.New(sha256.New, []byte(key))
h.Write([]byte(message))
sign := base64.StdEncoding.EncodeToString(h.Sum(nil))
// Remove last character as in Python
if len(sign) > 0 {
sign = sign[:len(sign)-1]
}
// Test our function with fixed time logic
sign2 := GetFileInfoSign(trackID, quality, codecs, transport, key)
_ = sign2 // Just verify no panic
// Verify the algorithm produces expected format
if sign == "" {
t.Error("Expected non-empty computed sign")
}
if sign[len(sign)-1] == '=' {
t.Error("Sign should not end with '=' (last char removed)")
}
}
// TestJoinCodecs tests the joinCodecs helper function
func TestJoinCodecs(t *testing.T) {
tests := []struct {
name string
codecs []string
expected string
}{
{"empty", []string{}, ""},
{"single", []string{"mp3"}, "mp3"},
{"multiple", []string{"flac", "aac", "mp3"}, "flacaacmp3"},
{"with_special_chars", []string{"opus", "aac+"}, "opusaac+"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := joinCodecs(tc.codecs)
if result != tc.expected {
t.Errorf("joinCodecs(%v) = %q, want %q", tc.codecs, result, tc.expected)
}
})
}
}
// TestSignWithDifferentKeys tests that different keys produce different signatures
func TestSignWithDifferentKeys(t *testing.T) {
trackID := "12345"
key1 := "key1"
key2 := "key2"
sign1 := GetSignRequest(trackID, key1)
sign2 := GetSignRequest(trackID, key2)
if sign1.Value == sign2.Value {
t.Error("Different keys should produce different signatures")
}
}
// TestFileInfoSignWithDifferentKeys tests that different keys produce different signatures
func TestFileInfoSignWithDifferentKeys(t *testing.T) {
codecs := []string{"mp3", "flac"}
key1 := "key1"
key2 := "key2"
sign1 := GetFileInfoSign("12345", "high", codecs, "http", key1)
sign2 := GetFileInfoSign("12345", "high", codecs, "http", key2)
if sign1.Value == sign2.Value {
t.Error("Different keys should produce different signatures")
}
}
// TestFileInfoSignRemovesLastChar tests that the last character is removed from base64 output
func TestFileInfoSignRemovesLastChar(t *testing.T) {
codecs := []string{"mp3"}
sign := GetFileInfoSign("12345", "high", codecs, "http", "test-key")
// The sign should not end with '=' (padding) because we remove the last char
// Note: it could end with '=' if the original base64 ended with '==', but we remove one char
_ = sign
// This is more of a sanity check that the function runs
}
// TestDefaultSignKey tests that default keys are used when empty string provided
func TestDefaultSignKey(t *testing.T) {
// GetSignRequest should use DefaultSignKey when key is empty
sign1 := GetSignRequest("12345", "")
sign2 := GetSignRequest("12345", DefaultSignKey)
// They should produce same result (timestamps will differ, but algorithm same)
if sign1.Value == "" || sign2.Value == "" {
t.Error("Both should produce non-empty signatures")
}
// GetFileInfoSign should use DesktopSignKey when key is empty
sign3 := GetFileInfoSign("12345", "high", []string{"mp3"}, "http", "")
sign4 := GetFileInfoSign("12345", "high", []string{"mp3"}, "http", DesktopSignKey)
if sign3.Value == "" || sign4.Value == "" {
t.Error("Both should produce non-empty signatures")
}
}
// TestTimestampIsRecent tests that timestamp is close to current time
func TestTimestampIsRecent(t *testing.T) {
before := time.Now().Unix()
sign := GetSignRequest("12345", "test-key")
after := time.Now().Unix()
if sign.Timestamp < before || sign.Timestamp > after {
t.Errorf("Timestamp %d not in range [%d, %d]", sign.Timestamp, before, after)
}
}