Files
alice-backend/music/main.go
T

145 lines
4.1 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"`
} `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"}
action, ok := actions[req.Arguments.Action]
if !ok {
return nil, errors.New("invalid_music_action")
}
command["command"] = action
if action == "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 {
return nil, errors.New("spotify_not_paired")
}
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 "command_result":
if event["command"] == command["command"] {
accepted = true
if err := conn.WriteJSON(map[string]string{"type": "command", "command": "get_state"}); err != nil {
return nil, errors.New("spotify_disconnected")
}
}
case "playback_state":
if accepted {
return map[string]interface{}{"accepted": true, "status": event["status"], "item": event["item"], "volume": event["volume"]}, nil
}
}
}
}
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))
}