Initial commit
This commit is contained in:
commit
02a6dabcfb
62 files changed
+15751
No files matched your search
@@ -0,0 +1,400 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user