package ynison import ( "context" "encoding/json" "fmt" "sync" "time" "gitea.mrixs.me/Mrixs/yandex-music-api-go/internal/client" "gitea.mrixs.me/Mrixs/yandex-music-api-go/internal/ynison/proto" "github.com/gorilla/websocket" ) // YnisonClient represents a Ynison WebSocket client type YnisonClient struct { client *client.Client conn *websocket.Conn deviceID string sessionID int64 mu sync.Mutex handlers map[string]func(*proto.PutYnisonStateResponse) ridCounter int64 connected bool reconnectCh chan struct{} doneCh chan struct{} wsURL string // For testing: override WebSocket URL closeOnce sync.Once // Ensures doneCh is closed only once } // DeviceConfig holds device configuration for Ynison type DeviceConfig struct { DeviceID string Title string DeviceType proto.DeviceType AppName string AppVersion string CanBePlayer bool CanBeRemote bool VolumeGran int } // NewYnisonClient creates a new Ynison client func NewYnisonClient(c *client.Client, config DeviceConfig) *YnisonClient { if config.DeviceID == "" { config.DeviceID = fmt.Sprintf("go-ynison-%d", time.Now().UnixNano()) } if config.Title == "" { config.Title = "Go Ynison Client" } if config.AppName == "" { config.AppName = "YandexMusicGo" } if config.AppVersion == "" { config.AppVersion = "1.0.0" } if config.VolumeGran == 0 { config.VolumeGran = 100 } return &YnisonClient{ client: c, deviceID: config.DeviceID, handlers: make(map[string]func(*proto.PutYnisonStateResponse)), reconnectCh: make(chan struct{}, 1), doneCh: make(chan struct{}), } } // Connect establishes a WebSocket connection to Ynison func (y *YnisonClient) Connect(ctx context.Context) error { token := y.client.GetToken() if token == "" { return fmt.Errorf("no auth token available") } wsURL := y.wsURL if wsURL == "" { wsURL = fmt.Sprintf("wss://ynison.music.yandex.ru/redirector.YnisonRedirector/Connect?device_id=%s&token=%s", y.deviceID, token) } dialer := websocket.DefaultDialer conn, _, err := dialer.DialContext(ctx, wsURL, nil) if err != nil { return fmt.Errorf("failed to connect to Ynison: %w", err) } y.conn = conn y.connected = true // Start reading messages go y.readLoop(ctx) // Send initial UpdateFullState return y.sendUpdateFullState(ctx) } // overrideWSURL sets a custom WebSocket URL for testing func (y *YnisonClient) overrideWSURL(url string) { y.wsURL = url } // Disconnect closes the WebSocket connection func (y *YnisonClient) Disconnect() error { y.mu.Lock() defer y.mu.Unlock() if y.conn != nil { y.connected = false y.closeOnce.Do(func() { close(y.doneCh) }) err := y.conn.Close() y.conn = nil // Prevent double close return err } return nil } // IsConnected returns true if the client is connected func (y *YnisonClient) IsConnected() bool { y.mu.Lock() defer y.mu.Unlock() return y.connected } // readLoop reads messages from the WebSocket connection func (y *YnisonClient) readLoop(ctx context.Context) { defer func() { y.mu.Lock() y.connected = false y.mu.Unlock() y.closeOnce.Do(func() { close(y.doneCh) }) }() for { select { case <-ctx.Done(): return case <-y.doneCh: return default: _, message, err := y.conn.ReadMessage() if err != nil { if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { fmt.Printf("Ynison read error: %v\n", err) } return } var resp proto.PutYnisonStateResponse if err := json.Unmarshal(message, &resp); err != nil { fmt.Printf("Ynison unmarshal error: %v\n", err) continue } // Call registered handlers y.mu.Lock() for _, handler := range y.handlers { go handler(&resp) } y.mu.Unlock() } } } // sendUpdateFullState sends the initial full state update func (y *YnisonClient) sendUpdateFullState(ctx context.Context) error { req := &proto.PutYnisonStateRequest{ UpdateFullState: &proto.UpdateFullState{ PlayerState: &proto.PlayerState{ Status: &proto.PlayingStatus{ ProgressMs: 0, DurationMs: 0, Paused: true, PlaybackSpeed: 1.0, Version: &proto.UpdateVersion{ DeviceId: y.deviceID, Version: 1, TimestampMs: time.Now().UnixMilli(), }, }, PlayerQueue: &proto.PlayerQueue{ EntityId: "", EntityType: proto.PlayerQueueEntityType_ENTITY_TYPE_UNSPECIFIED, CurrentPlayableIndex: -1, Options: &proto.PlayerStateOptions{ RepeatMode: proto.PlayerStateOptionsRepeatMode_REPEAT_MODE_NONE, }, Version: &proto.UpdateVersion{ DeviceId: y.deviceID, Version: 1, TimestampMs: time.Now().UnixMilli(), }, EntityContext: proto.PlayerQueueEntityContext_ENTITY_CONTEXT_UNSPECIFIED, }, PlayerQueueInjectOptional: &proto.PlayerQueueInject{ PlayingStatus: &proto.PlayingStatus{ ProgressMs: 0, DurationMs: 0, Paused: true, PlaybackSpeed: 1.0, Version: &proto.UpdateVersion{ DeviceId: y.deviceID, Version: 1, TimestampMs: time.Now().UnixMilli(), }, }, Playable: &proto.PlayerQueueInjectPlayable{ PlayableId: "", PlayableType: proto.PlayerQueueInjectPlayablePlayableType_INJECT_PLAYABLE_TYPE_UNSPECIFIED, Title: "", }, Version: &proto.UpdateVersion{ DeviceId: y.deviceID, Version: 1, TimestampMs: time.Now().UnixMilli(), }, }, }, IsCurrentlyActive: false, Device: &proto.UpdateDevice{ Info: &proto.DeviceInfo{ DeviceId: y.deviceID, Title: "Go Ynison Client", Type: proto.DeviceType_DEVICE_TYPE_WEB_DESKTOP, AppName: "YandexMusicGo", AppVersion: "1.0.0", }, Volume: 0.5, Capabilities: &proto.DeviceCapabilities{ CanBePlayer: true, CanBeRemoteController: true, VolumeGranularity: 100, }, VolumeInfo: &proto.DeviceVolume{ Volume: 0.5, Version: &proto.UpdateVersion{ DeviceId: y.deviceID, Version: 1, TimestampMs: time.Now().UnixMilli(), }, }, }, }, PlayerActionTimestampMs: time.Now().UnixMilli(), Rid: y.nextRid(), ActivityInterceptionType: proto.PutYnisonStateRequestActivityInterceptionType_INTERCEPTION_DO_NOT_INTERCEPT_BY_DEFAULT, } return y.sendRequest(ctx, req) } // nextRid generates the next request ID func (y *YnisonClient) nextRid() string { y.ridCounter++ return fmt.Sprintf("%s-%d", y.deviceID, y.ridCounter) } // sendRequest sends a request to the Ynison server func (y *YnisonClient) sendRequest(ctx context.Context, req *proto.PutYnisonStateRequest) error { y.mu.Lock() defer y.mu.Unlock() if !y.connected || y.conn == nil { return fmt.Errorf("not connected") } data, err := json.Marshal(req) if err != nil { return fmt.Errorf("failed to marshal request: %w", err) } return y.conn.WriteMessage(websocket.TextMessage, data) } // RegisterHandler registers a response handler func (y *YnisonClient) RegisterHandler(name string, handler func(*proto.PutYnisonStateResponse)) { y.mu.Lock() defer y.mu.Unlock() y.handlers[name] = handler } // UnregisterHandler removes a response handler func (y *YnisonClient) UnregisterHandler(name string) { y.mu.Lock() defer y.mu.Unlock() delete(y.handlers, name) } // UpdatePlayingStatus updates the playing status func (y *YnisonClient) UpdatePlayingStatus(ctx context.Context, status *proto.PlayingStatus) error { req := &proto.PutYnisonStateRequest{ UpdatePlayingStatus: &proto.UpdatePlayingStatus{ PlayingStatus: status, }, PlayerActionTimestampMs: time.Now().UnixMilli(), Rid: y.nextRid(), } return y.sendRequest(ctx, req) } // UpdateVolume updates the volume func (y *YnisonClient) UpdateVolume(ctx context.Context, deviceID string, volume float64, version *proto.UpdateVersion) error { req := &proto.PutYnisonStateRequest{ UpdateVolumeInfo: &proto.UpdateVolumeInfo{ DeviceId: deviceID, VolumeInfo: &proto.DeviceVolume{ Volume: volume, Version: version, }, }, PlayerActionTimestampMs: time.Now().UnixMilli(), Rid: y.nextRid(), } return y.sendRequest(ctx, req) } // UpdateActiveDevice updates the active device func (y *YnisonClient) UpdateActiveDevice(ctx context.Context, deviceID string) error { req := &proto.PutYnisonStateRequest{ UpdateActiveDevice: &proto.UpdateActiveDevice{ DeviceIdOptional: deviceID, }, PlayerActionTimestampMs: time.Now().UnixMilli(), Rid: y.nextRid(), } return y.sendRequest(ctx, req) } // UpdatePlayerState updates the player state (queue, repeat, shuffle) func (y *YnisonClient) UpdatePlayerState(ctx context.Context, state *proto.PlayerState) error { req := &proto.PutYnisonStateRequest{ UpdatePlayerState: &proto.UpdatePlayerState{ PlayerState: state, }, PlayerActionTimestampMs: time.Now().UnixMilli(), Rid: y.nextRid(), } return y.sendRequest(ctx, req) } // UpdatePlayerQueueInject updates the injected queue entity func (y *YnisonClient) UpdatePlayerQueueInject(ctx context.Context, inject *proto.PlayerQueueInject) error { req := &proto.PutYnisonStateRequest{ UpdatePlayerQueueInject: &proto.UpdatePlayerQueueInject{ PlayerQueueInject: inject, }, PlayerActionTimestampMs: time.Now().UnixMilli(), Rid: y.nextRid(), } return y.sendRequest(ctx, req) } // SyncStateFromEOV requests synchronization from EOV func (y *YnisonClient) SyncStateFromEOV(ctx context.Context, actualQueueID string) error { req := &proto.PutYnisonStateRequest{ SyncStateFromEov: &proto.SyncStateFromEOV{ ActualQueueId: actualQueueID, }, PlayerActionTimestampMs: time.Now().UnixMilli(), Rid: y.nextRid(), } return y.sendRequest(ctx, req) } // GetDevices returns the list of connected devices from the last response func (y *YnisonClient) GetDevices() []*proto.Device { // This would need to be implemented with proper state tracking return nil } // GetActiveDeviceID returns the active device ID from the last response func (y *YnisonClient) GetActiveDeviceID() string { // This would need to be implemented with proper state tracking return "" } // GetPlayerState returns the current player state from the last response func (y *YnisonClient) GetPlayerState() *proto.PlayerState { // This would need to be implemented with proper state tracking return nil } // WaitForConnection waits for the connection to be established or context to be done func (y *YnisonClient) WaitForConnection(ctx context.Context) error { select { case <-ctx.Done(): return ctx.Err() case <-y.doneCh: return fmt.Errorf("connection closed") default: if y.IsConnected() { return nil } return fmt.Errorf("not connected") } }