Files

277 lines
8.7 KiB
Go

package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"os/exec"
"regexp"
"strings"
"time"
"github.com/gorilla/websocket"
)
type request struct {
Name string `json:"name"`
Arguments struct {
URI string `json:"uri"`
Action string `json:"action"`
Volume *int `json:"volume"`
Active bool `json:"active"`
PositionMs *int `json:"positionMs"`
Enabled *bool `json:"enabled"`
Mode string `json:"mode"`
} `json:"arguments"`
}
var validURI = regexp.MustCompile(`^spotify:(track|artist|album|playlist|episode):[A-Za-z0-9]{22}$`)
func execute(req request) (map[string]interface{}, error) {
if req.Name == "duck" {
volume := "100%"
if req.Arguments.Active {
volume = "75%"
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "/data/opt/alice-backend/spotify/pactl", "set-sink-volume", "music", volume)
if err := cmd.Run(); err != nil {
return nil, errors.New("music_audio_unavailable")
}
return map[string]interface{}{"ok": true}, nil
}
command := map[string]interface{}{"type": "command"}
if req.Name == "spotify_play" {
if !validURI.MatchString(req.Arguments.URI) {
return nil, errors.New("invalid_spotify_uri")
}
command["command"] = "play"
command["uri"] = req.Arguments.URI
} else if req.Name == "spotify_control" {
actions := map[string]string{
"pause": "pause", "resume": "play", "next": "skip_next", "previous": "skip_prev",
"volume": "set_volume", "seek": "seek", "shuffle": "set_shuffle", "repeat": "set_repeat_context",
"queue": "get_queue", "enqueue": "add_to_queue", "status": "get_state",
"toggle": "get_state", "swipe_previous": "get_state",
}
action, ok := actions[req.Arguments.Action]
if !ok {
return nil, errors.New("invalid_music_action")
}
command["command"] = action
switch action {
case "get_queue":
command["limit"] = 6
case "add_to_queue":
if !validURI.MatchString(req.Arguments.URI) || !strings.HasPrefix(req.Arguments.URI, "spotify:track:") {
return nil, errors.New("invalid_spotify_uri")
}
command["uri"] = req.Arguments.URI
case "seek":
if req.Arguments.PositionMs == nil || *req.Arguments.PositionMs < 0 || *req.Arguments.PositionMs > 86400000 {
return nil, errors.New("invalid_spotify_position")
}
command["position_ms"] = *req.Arguments.PositionMs
case "set_shuffle":
if req.Arguments.Enabled == nil {
return nil, errors.New("invalid_spotify_shuffle")
}
command["enabled"] = *req.Arguments.Enabled
case "set_repeat_context":
switch req.Arguments.Mode {
case "off":
command["enabled"] = false
case "context":
command["enabled"] = true
case "track":
command["command"] = "set_repeat_track"
command["enabled"] = true
default:
return nil, errors.New("invalid_spotify_repeat")
}
case "set_volume":
if req.Arguments.Volume == nil || *req.Arguments.Volume < 0 || *req.Arguments.Volume > 100 {
return nil, errors.New("invalid_music_volume")
}
command["volume"] = *req.Arguments.Volume
}
} else {
return nil, errors.New("unknown_music_tool")
}
dialer := websocket.Dialer{HandshakeTimeout: 2 * time.Second}
conn, _, err := dialer.Dial("ws://127.0.0.1:9090", nil)
if err != nil {
return nil, errors.New("spotify_unavailable")
}
defer conn.Close()
conn.SetReadLimit(256 * 1024)
deadline := time.Now().Add(10 * time.Second)
_ = conn.SetReadDeadline(deadline)
_ = conn.SetWriteDeadline(deadline)
var auth struct {
Type string `json:"type"`
LoggedIn bool `json:"logged_in"`
}
if err := conn.ReadJSON(&auth); err != nil || auth.Type != "auth_state" || !auth.LoggedIn {
if command["command"] == "get_state" && err == nil {
return map[string]interface{}{"connected": false, "status": "unpaired"}, nil
}
return nil, errors.New("spotify_not_paired")
}
panelAction := ""
if req.Arguments.Action == "toggle" || req.Arguments.Action == "swipe_previous" {
if err := conn.WriteJSON(command); err != nil {
return nil, errors.New("spotify_disconnected")
}
for {
var event struct {
Type, Status string
Position struct {
Milliseconds int64 `json:"position_ms"`
}
}
if err := conn.ReadJSON(&event); err != nil || event.Type == "error" {
return nil, errors.New("spotify_state_unavailable")
}
if event.Type != "playback_state" {
continue
}
if req.Arguments.Action == "toggle" {
panelAction = "resume"
command["command"] = "play"
if event.Status == "playing" || event.Status == "buffering" {
panelAction = "pause"
command["command"] = "pause"
}
} else {
panelAction = "previous"
command["command"] = "skip_prev"
if event.Position.Milliseconds >= 5000 {
command["command"] = "seek"
command["position_ms"] = 0
}
}
break
}
}
// Soloist exposes context and track repeat as separate flags.
if req.Arguments.Action == "repeat" {
reset := "set_repeat_track"
if req.Arguments.Mode == "track" {
reset = "set_repeat_context"
}
if err := conn.WriteJSON(map[string]interface{}{"type": "command", "command": reset, "enabled": false}); err != nil {
return nil, errors.New("spotify_disconnected")
}
for {
var event struct{ Type, Command string }
if err := conn.ReadJSON(&event); err != nil || event.Type == "error" {
return nil, errors.New("spotify_repeat_failed")
}
if event.Type == "command_result" && event.Command == reset {
break
}
}
}
if err := conn.WriteJSON(command); err != nil {
return nil, errors.New("spotify_disconnected")
}
accepted := false
for {
var event map[string]interface{}
if err := conn.ReadJSON(&event); err != nil {
if accepted {
return map[string]interface{}{"accepted": true, "status": "pending"}, nil
}
return nil, errors.New("spotify_response_timeout")
}
switch event["type"] {
case "error":
return nil, errors.New("spotify_command_failed")
case "playback_changed":
if accepted && req.Arguments.Action == "pause" && event["status"] == "paused" {
return map[string]interface{}{"accepted": true, "status": "paused"}, nil
}
case "command_result":
if event["command"] == command["command"] {
accepted = true
if panelAction != "" {
return map[string]interface{}{"accepted": true, "action": panelAction}, nil
}
if err := conn.WriteJSON(map[string]string{"type": "command", "command": "get_state"}); err != nil {
return nil, errors.New("spotify_disconnected")
}
}
case "queue_changed":
if command["command"] == "get_queue" {
entries, _ := event["upcoming"].([]interface{})
items := make([]interface{}, 0, min(6, len(entries)))
for _, entry := range entries[:min(6, len(entries))] {
if entry, ok := entry.(map[string]interface{}); ok {
items = append(items, compactEntity(entry["item"]))
}
}
return map[string]interface{}{"upcoming": items}, nil
}
case "playback_state":
if accepted || command["command"] == "get_state" {
if req.Arguments.Action == "pause" && event["status"] != "paused" && event["status"] != "idle" {
continue
}
result := map[string]interface{}{"accepted": accepted, "connected": true, "status": event["status"], "active": event["is_active"],
"item": compactEntity(event["item"]), "context": compactEntity(event["context"]), "position": event["position"], "options": event["options"]}
if req.Arguments.Action == "volume" {
result["volume"] = event["volume"]
}
return result, nil
}
}
}
}
func compactEntity(raw interface{}) interface{} {
entity, ok := raw.(map[string]interface{})
if !ok {
return nil
}
decorations, _ := entity["decorations"].(map[string]interface{})
identity, _ := decorations["identity"].(map[string]interface{})
return map[string]interface{}{"uri": entity["uri"], "name": identity["name"], "artists": identity["artists"], "album": identity["album"]}
}
func main() {
cacheDir := flag.String("cache-janitor", "", "maintain the dedicated Soloist cache directory")
flag.Parse()
if *cacheDir != "" {
if err := runCacheJanitor(*cacheDir); err != nil {
fmt.Fprintln(os.Stderr, "spotify cache:", err)
os.Exit(1)
}
return
}
var req request
decoder := json.NewDecoder(io.LimitReader(os.Stdin, 8193))
decoder.DisallowUnknownFields()
var result map[string]interface{}
if err := decoder.Decode(&req); err != nil {
result = map[string]interface{}{"error": "invalid_music_request"}
} else {
var err error
result, err = execute(req)
if err != nil {
result = map[string]interface{}{"error": strings.TrimSpace(err.Error())}
}
}
data, err := json.Marshal(result)
if err != nil || len(data) > 12000 {
data = []byte(`{"error":"music_result_too_large"}`)
}
fmt.Println(string(data))
}