diff --git a/cmd/gonic/gonic.go b/cmd/gonic/gonic.go index 3235e1d7..328004bd 100644 --- a/cmd/gonic/gonic.go +++ b/cmd/gonic/gonic.go @@ -44,6 +44,7 @@ import ( "go.senan.xyz/gonic/scrobble" "go.senan.xyz/gonic/server/ctrladmin" "go.senan.xyz/gonic/server/ctrlsubsonic" + "go.senan.xyz/gonic/server/mpd" "go.senan.xyz/gonic/tags/tagcommon" "go.senan.xyz/gonic/tags/taglib" "go.senan.xyz/gonic/transcode" @@ -51,6 +52,7 @@ import ( func main() { confListenAddr := flag.String("listen-addr", "0.0.0.0:4747", "listen address (optional)") + confMPDListenAddr := flag.String("mpd-listen-addr", "0.0.0.0:6600", "listen address for MPD protocol (optional)") confTLSCert := flag.String("tls-cert", "", "path to TLS certificate (optional)") confTLSKey := flag.String("tls-key", "", "path to TLS private key (optional)") @@ -318,6 +320,17 @@ func main() { return server.ListenAndServe() }) + errgrp.Go(func() error { + defer logJob("mpd")() + + server, err := mpd.New(jukebx) + if err != nil { + return fmt.Errorf("mpd init: %w", err) + } + + return server.ListenAndServe(ctx, *confMPDListenAddr) + }) + errgrp.Go(func() error { if !*confScanWatcher { return nil diff --git a/jukebox/jukebox.go b/jukebox/jukebox.go index 476864b6..f64c0aa0 100644 --- a/jukebox/jukebox.go +++ b/jukebox/jukebox.go @@ -237,20 +237,32 @@ func (j *Jukebox) ClearPlaylist() error { } func (j *Jukebox) Pause() error { + return j.SetPlay(false) +} + +func (j *Jukebox) Play() error { + return j.SetPlay(true) +} + +func (j *Jukebox) SetPlay(state bool) error { defer lock(&j.mu)() - if err := j.conn.Set("pause", true); err != nil { + pause := !state + + if err := j.conn.Set("pause", pause); err != nil { return fmt.Errorf("pause: %w", err) } + return nil } -func (j *Jukebox) Play() error { +func (j *Jukebox) TogglePlay() error { defer lock(&j.mu)() - if err := j.conn.Set("pause", false); err != nil { - return fmt.Errorf("pause: %w", err) + if _, err := j.conn.Call("cycle", "pause"); err != nil { + return fmt.Errorf("cycle pause: %w", err) } + return nil } diff --git a/server/mpd/error.go b/server/mpd/error.go new file mode 100644 index 00000000..badbd283 --- /dev/null +++ b/server/mpd/error.go @@ -0,0 +1,46 @@ +package mpd + +import "fmt" + +// ACK error codes +// https://github.com/MusicPlayerDaemon/MPD/blob/master/src/protocol/Ack.hxx +type ackError uint + +const ( + ackErrorNotList ackError = 1 + ackErrorArg = 2 + ackErrorPassword = 3 + ackErrorPermission = 4 + ackErrorUnknown = 5 + + ackErrorNoExist = 50 + ackErrorPlaylistMax = 51 + ackErrorSystem = 52 + ackErrorPlaylistLoad = 53 + ackErrorUpdateAlready = 54 + ackErrorPlayerSync = 55 + ackErrorExist = 56 +) + +type errorResponse struct { + code ackError + cmdListNum uint + cmdName string + err error +} + +func newError(code ackError, cmdIdx uint, cmdName string, err error) *errorResponse { + return &errorResponse{code, cmdIdx, cmdName, err} +} + +func (e errorResponse) String() string { + return fmt.Sprintf("ACK [%d@%d] {%s} %s", e.code, e.cmdListNum, e.cmdName, e.err.Error()) +} + +func (e errorResponse) Error() string { + return fmt.Sprintf("%s: %v", e.cmdName, e.err) +} + +func (e errorResponse) Unwrap() error { + return e.err +} diff --git a/server/mpd/parse.go b/server/mpd/parse.go new file mode 100644 index 00000000..d9f72726 --- /dev/null +++ b/server/mpd/parse.go @@ -0,0 +1,97 @@ +package mpd + +import ( + "bufio" + "strings" +) + +type lineReader struct { + inner *bufio.Reader + idx uint +} + +func newLineReader(rd *bufio.Reader) *lineReader { + return &lineReader{rd, 0} +} + +func (lr *lineReader) Next() (uint, string, error) { + line, err := lr.inner.ReadString('\n') + if err != nil { + return 0, "", err + } + + lr.idx += 1 + line = strings.TrimSuffix(line, "\n") + + return lr.idx, line, nil +} + +// argParser splits a line into a sequence of arguments. +// The first argument is mandatory and is the command. +type argParser struct { + line string + buff []rune + idx uint // argument index +} + +func newArgParser(line string) *argParser { + return &argParser{line, []rune(line), 0} +} + +func (p *argParser) pos() int { + return len(p.line) - len(p.buff) +} + +func (p *argParser) Next() (string, bool) { + const ( + linearSpace = " \t" + + stateSpace = iota + stateArg + stateQuote + ) + + if len(p.buff) == 0 { + return "", false + } + + res := make([]rune, 0, len(p.buff)) + + i := 0 +loop: + for state := stateSpace; i < len(p.buff); i++ { + r := p.buff[i] + isSpace := strings.IndexRune(linearSpace, r) != -1 + + if state == stateSpace { + if isSpace { + continue + } + + state = stateArg + } + + switch { + case state == stateArg && isSpace: + break loop // space -> end of argument + + case state == stateArg && r == '"': + state = stateQuote // quote begin + continue + + case state == stateQuote && r == '\\': + i++ // skip the backslash + + case state == stateQuote && r == '"': + state = stateArg // quote end + continue + } + + res = append(res, p.buff[i]) + } + + p.idx += 1 + p.buff = p.buff[i:] + + return string(res), true +} diff --git a/server/mpd/parse_test.go b/server/mpd/parse_test.go new file mode 100644 index 00000000..f973ddf2 --- /dev/null +++ b/server/mpd/parse_test.go @@ -0,0 +1,32 @@ +package mpd + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestArgParser(t *testing.T) { + t.Parallel() + + expectations := map[string][]string{ + `cmd`: {"cmd"}, + `cmd arg`: {"cmd", "arg"}, + `cmd "arg one" arg-two`: {"cmd", "arg one", "arg-two"}, + `find "(Artist == \"foo\\'bar\\\"\")"`: {"find", `(Artist == "foo\'bar\"")`}, + } + + for line, args := range expectations { + p := newArgParser(line) + + for i, expected := range args { + parsed, ok := p.Next() + assert.True(t, ok) + assert.Equal(t, uint(i+1), p.idx) + assert.Equal(t, expected, parsed) + } + + arg, ok := p.Next() + assert.False(t, ok, "parser return extra arg: %q", arg) + } +} diff --git a/server/mpd/server.go b/server/mpd/server.go new file mode 100644 index 00000000..92a83b0f --- /dev/null +++ b/server/mpd/server.go @@ -0,0 +1,326 @@ +package mpd + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "log" + "net" + "net/netip" + + "go.senan.xyz/gonic/jukebox" +) + +const ( + protocolVersion = "0" + protocolHello = "OK MPD " + protocolVersion +) + +var errCmdListEnd = errors.New("command_list_end") + +type Server struct { + jukebox *jukebox.Jukebox +} + +func New(jukebx *jukebox.Jukebox) (*Server, error) { + s := &Server{ + jukebx, + } + + return s, nil +} + +func (s *Server) ListenAndServe(ctx context.Context, addr string) error { + addrPort, err := netip.ParseAddrPort(addr) + if err != nil { + return err + } + + l, err := net.ListenTCP("tcp", net.TCPAddrFromAddrPort(addrPort)) + if err != nil { + return nil + } + + return s.Serve(ctx, l) +} + +func (s *Server) Serve(ctx context.Context, l *net.TCPListener) error { + go func() { + <-ctx.Done() + _ = l.Close() + }() + + for { + conn, err := l.AcceptTCP() + if err != nil { + return err + } + + go func() { + conn := connection{s, conn} + + err := conn.handle(ctx) + if err != nil { + log.Printf("error handling MPD client: %s: %v", conn.RemoteAddr(), err) + } + }() + } +} + +type client struct { + srv *Server + + lines *lineReader + out io.Writer + + cmdIdx uint + cmdName string +} + +func newClient(srv *Server, conn *net.TCPConn) *client { + lines := newLineReader(bufio.NewReader(conn)) + + return &client{ + srv: srv, + + lines: lines, + out: conn, + } +} + +func (c *client) writeLine(line string) error { + _, err := io.WriteString(c.out, line+"\n") + return err +} + +func (c *client) writePair(name, value string) error { + return c.writeLine(fmt.Sprintf("%s: %s", name, value)) +} + +func (c *client) newErr(code ackError, err error) error { + return newError(code, c.cmdIdx, c.cmdName, err) +} + +func (c *client) nextCmd() (string, *argParser, error) { + cmdIdx, line, err := c.lines.Next() + if err != nil { + return "", nil, fmt.Errorf("could not read command %d: %w", cmdIdx, err) + } + + args := newArgParser(line) + + cmd, ok := args.Next() + if !ok { + return "", nil, errors.New("empty command") + } + + return cmd, args, nil +} + +func (s *Server) handle(c *client) error { + c.writeLine(protocolHello) + + for { + cmd, args, err := c.nextCmd() + if err != nil { + if errors.Is(err, io.EOF) { + return nil + } + + return err + } + + err = doCmd(c, cmd, args) + if err != nil { + if errors.Is(err, errCmdListEnd) { + err = c.newErr(ackErrorNotList, errors.New("no command list in progress")) + } + + var errRsp *errorResponse + if errors.As(err, &errRsp) { + c.writeLine(errRsp.String()) + return nil + } + + return err + } + + err = c.writeLine("OK") + if err != nil { + return err + } + } +} + +type cmdHandler func(c *client, args *argParser) error + +var cmdHandlers map[string]cmdHandler + +//nolint:noinit // initializing `cmdHandlers` inline causes a ref-loop build error +func init() { + cmdHandlers = map[string]cmdHandler{ + "command_list_begin": doCmdListBegin, + "command_list_end": doCmdListEnd, + "command_list_ok_begin": doCmdListOkBegin, + "currentsong": doCurrentSong, + "pause": doPause, + "play": doPlay, + "status": doStatus, + } +} + +func doCmd(c *client, name string, args *argParser) error { + fmt.Println("->", args.line) + + handler, ok := cmdHandlers[name] + if !ok { + return c.newErr(ackErrorNotList, fmt.Errorf("unknown command: %s", name)) + } + + return handler(c, args) +} + +// parseArgs returns an array of values matching names, or an error. +func parseArgs(c *client, args *argParser, names ...string) ([]string, error) { + values := make([]string, 0, len(names)) + + for i := range names { + val, ok := args.Next() + if !ok { + return nil, c.newErr(ackErrorArg, fmt.Errorf("got only %d args, missing: %s", i+1, names[i:])) + } + + values = append(values, val) + } + + extra, ok := args.Next() + if ok { + return nil, c.newErr(ackErrorArg, fmt.Errorf("too many args, first extra value: %s", extra)) + } + + return values, nil +} + +func doCmdListOkBegin(c *client, args *argParser) error { + return handleCmdList(c, args, true) +} + +func doCmdListBegin(c *client, args *argParser) error { + return handleCmdList(c, args, false) +} + +func handleCmdList(c *client, args *argParser, sendOk bool) error { + if _, err := parseArgs(c, args); err != nil { + return err + } + + for { + cmd, args, err := c.nextCmd() + if err != nil { + return err + } + + err = doCmd(c, cmd, args) + if err != nil { + if errors.Is(err, errCmdListEnd) { + return nil + } + + return err + } + + if sendOk { + c.writeLine("list_OK") + } + } +} + +func doCmdListEnd(c *client, args *argParser) error { + if _, err := parseArgs(c, args); err != nil { + return err + } + + return errCmdListEnd +} + +func doCurrentSong(c *client, args *argParser) error { + if _, err := parseArgs(c, args); err != nil { + return err + } + + if c.srv.jukebox == nil { + return nil + } + + status, err := c.srv.jukebox.GetStatus() + if err != nil { + return err + } + + if status.CurrentIndex < 0 { + return nil + } + + c.writePair("file", status.CurrentFilename) + + return nil +} + +func doPlay(c *client, args *argParser) error { + songpos, ok := args.Next() + if !ok { + return c.srv.jukebox.Play() + } + + i, err := strconv.Atoi(songpos) + if err != nil { + return c.newErr(ackErrorArg, fmt.Errorf("invalid SONGPOS: %w", err)) + } + + return c.srv.jukebox.SkipToPlaylistIndex(i, 0) +} + +func doPause(c *client, args *argParser) error { + state, ok := args.Next() + switch { + case !ok: // no arg, toggle + return c.srv.jukebox.TogglePlay() + + case state == "1" || state == "0": + return c.srv.jukebox.SetPlay(state == "0") + + default: + return c.newErr(ackErrorArg, fmt.Errorf("play state must be 0 or 1, got: %s", state)) + } +} + +func doStatus(c *client, args *argParser) error { + if _, err := parseArgs(c, args); err != nil { + return err + } + + if c.srv.jukebox == nil { + c.writePair("state", "stop") + return nil + } + + status, err := c.srv.jukebox.GetStatus() + if err != nil { + return err + } + + if status.CurrentIndex < 0 { + c.writePair("state", "stop") + return nil + } + + if status.Playing { + c.writePair("state", "play") + } else { + c.writePair("state", "pause") + } + + return nil +}