- 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>
405 lines
11 KiB
Go
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")
|
|
}
|
|
}
|