Files
MrixsandClaude Code 0456ca0665 Add comprehensive godoc documentation to all exported symbols
- 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>
2026-10-18 08:57:43 +03:00

405 lines
11 KiB
Go

// Package ynison provides a Ynison WebSocket client for player synchronization across devices.
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 for player synchronization across devices.
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 WebSocket connections.
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 with the given client and device config.
// Default values are applied for empty fields.
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 the Ynison service.
// It sends an initial full state update after connecting.
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 purposes.
func (y *YnisonClient) overrideWSURL(url string) {
y.wsURL = url
}
// Disconnect closes the WebSocket connection and cleans up resources.
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 currently connected to the Ynison service.
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 for the given name.
// Handlers are invoked when a PutYnisonStateResponse is received from the server.
func (y *YnisonClient) RegisterHandler(name string, handler func(*proto.PutYnisonStateResponse)) {
y.mu.Lock()
defer y.mu.Unlock()
y.handlers[name] = handler
}
// UnregisterHandler removes a previously registered response handler by name.
func (y *YnisonClient) UnregisterHandler(name string) {
y.mu.Lock()
defer y.mu.Unlock()
delete(y.handlers, name)
}
// UpdatePlayingStatus updates the playing status on the Ynison server.
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 for a device on the Ynison server.
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 on the Ynison server.
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) on the Ynison server.
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 on the Ynison server.
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 (End of Verification).
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 the 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")
}
}