From 46656ce6089aaddce258436a6c89b7032d61a500 Mon Sep 17 00:00:00 2001 From: ThinkChaos Date: Tue, 5 Mar 2024 20:00:04 -0500 Subject: [PATCH 1/2] feat(mpd): proof-of-concept --- cmd/gonic/gonic.go | 13 +++ server/mpd/error.go | 46 ++++++++ server/mpd/parse.go | 97 ++++++++++++++++ server/mpd/parse_test.go | 32 +++++ server/mpd/server.go | 245 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 433 insertions(+) create mode 100644 server/mpd/error.go create mode 100644 server/mpd/parse.go create mode 100644 server/mpd/parse_test.go create mode 100644 server/mpd/server.go 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/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..76c53182 --- /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() (uint, string, bool) { + const ( + linearSpace = " \t" + + stateSpace = iota + stateArg + stateQuote + ) + + if len(p.buff) == 0 { + return p.idx, "", 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 p.idx, string(res), true +} diff --git a/server/mpd/parse_test.go b/server/mpd/parse_test.go new file mode 100644 index 00000000..44cc4b69 --- /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 { + j, parsed, ok := p.Next() + assert.True(t, ok) + assert.Equal(t, uint(i+1), j) + 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..7925f0bd --- /dev/null +++ b/server/mpd/server.go @@ -0,0 +1,245 @@ +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 connection struct { // TODO?: remove this type + *Server + *net.TCPConn +} + +func (c *connection) writeLine(line string) error { + n, err := io.WriteString(c.TCPConn, line+"\n") + _ = n + return err +} + +func (c *connection) writePair(name, value string) error { + return c.writeLine(fmt.Sprintf("%s: %s", name, value)) +} + +func (c *connection) handle(ctx context.Context) error { + defer c.Close() + + c.writeLine(protocolHello) + + lines := newLineReader(bufio.NewReader(c.TCPConn)) + + err := c.handleCmd(lines) + if err != nil { + var errRsp *errorResponse + if errors.As(err, &errRsp) { + c.writeLine(errRsp.String()) + return nil + } + + return err + } + + return c.writeLine("OK") +} + +func (c *connection) handleCmd(lines *lineReader) error { + cmdIdx, line, err := lines.Next() + if err != nil { + return fmt.Errorf("could not read command %d: %w", cmdIdx, err) + } + + args := newArgParser(line) + + _, cmd, ok := args.Next() + if !ok { + return errors.New("empty command") + } + + return c.doCmd(0, cmd, args, lines) +} + +func (c *connection) doCmd(idx uint, name string, args *argParser, lines *lineReader) error { + fmt.Println("->", name) + + switch name { + case "command_list_ok_begin": + return c.doCmdListOkBegin(args, lines) + case "command_list_end": + return c.doCmdListEnd(args) + case "currentsong": + return c.doCurrentSong(args) + case "status": + return c.doStatus(args) + } + + return newError(ackErrorNotList, idx, name, fmt.Errorf("unknown command: %s", name)) +} + +// parseArgs returns an array of values matching names, or an error. +func (c *connection) parseArgs(args *argParser, names ...string) ([]string, error) { + values := make([]string, 0, len(names)) + + for i := range names { + _, val, ok := args.Next() + if !ok { + idx := uint(0) // FIXME + name := "" // FIXME + return nil, newError(ackErrorArg, idx, name, + fmt.Errorf("got only %d args, missing: %s", i+1, names[i:])) + } + + values = append(values, val) + } + + _, extra, ok := args.Next() + if ok { + idx := uint(0) // FIXME + name := "" // FIXME + return nil, newError(ackErrorArg, idx, name, + fmt.Errorf("too many args, first extra value: %s", extra)) + } + + return values, nil +} + +func (c *connection) doCmdListOkBegin(args *argParser, lines *lineReader) error { + if _, err := c.parseArgs(args); err != nil { + return err + } + + for { + err := c.handleCmd(lines) + if err != nil { + if errors.Is(err, errCmdListEnd) { + return nil + } + + return err + } + + c.writeLine("list_OK") + } +} + +func (c *connection) doCmdListEnd(args *argParser) error { + if _, err := c.parseArgs(args); err != nil { + return err + } + + return errCmdListEnd +} + +func (c *connection) doCurrentSong(args *argParser) error { + if _, err := c.parseArgs(args); err != nil { + return err + } + + if c.jukebox == nil { + return nil + } + + status, err := c.jukebox.GetStatus() + if err != nil { + return err + } + + if status.CurrentIndex < 0 { + return nil + } + + c.writePair("file", status.CurrentFilename) + + return nil +} + +func (c *connection) doStatus(args *argParser) error { + if _, err := c.parseArgs(args); err != nil { + return err + } + + if c.jukebox == nil { + c.writePair("state", "stop") + return nil + } + + status, err := c.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 +} From 197d7c98182c680b9d3d341c6e1d2b00ea6d8861 Mon Sep 17 00:00:00 2001 From: ThinkChaos Date: Tue, 5 Mar 2024 19:48:49 -0500 Subject: [PATCH 2/2] feat(mpd): cleanup and functional play/pause --- jukebox/jukebox.go | 20 +++- server/mpd/parse.go | 6 +- server/mpd/parse_test.go | 6 +- server/mpd/server.go | 215 +++++++++++++++++++++++++++------------ 4 files changed, 170 insertions(+), 77 deletions(-) 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/parse.go b/server/mpd/parse.go index 76c53182..d9f72726 100644 --- a/server/mpd/parse.go +++ b/server/mpd/parse.go @@ -42,7 +42,7 @@ func (p *argParser) pos() int { return len(p.line) - len(p.buff) } -func (p *argParser) Next() (uint, string, bool) { +func (p *argParser) Next() (string, bool) { const ( linearSpace = " \t" @@ -52,7 +52,7 @@ func (p *argParser) Next() (uint, string, bool) { ) if len(p.buff) == 0 { - return p.idx, "", false + return "", false } res := make([]rune, 0, len(p.buff)) @@ -93,5 +93,5 @@ loop: p.idx += 1 p.buff = p.buff[i:] - return p.idx, string(res), true + return string(res), true } diff --git a/server/mpd/parse_test.go b/server/mpd/parse_test.go index 44cc4b69..f973ddf2 100644 --- a/server/mpd/parse_test.go +++ b/server/mpd/parse_test.go @@ -20,13 +20,13 @@ func TestArgParser(t *testing.T) { p := newArgParser(line) for i, expected := range args { - j, parsed, ok := p.Next() + parsed, ok := p.Next() assert.True(t, ok) - assert.Equal(t, uint(i+1), j) + assert.Equal(t, uint(i+1), p.idx) assert.Equal(t, expected, parsed) } - _, arg, ok := p.Next() + 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 index 7925f0bd..92a83b0f 100644 --- a/server/mpd/server.go +++ b/server/mpd/server.go @@ -69,109 +69,160 @@ func (s *Server) Serve(ctx context.Context, l *net.TCPListener) error { } } -type connection struct { // TODO?: remove this type - *Server - *net.TCPConn +type client struct { + srv *Server + + lines *lineReader + out io.Writer + + cmdIdx uint + cmdName string } -func (c *connection) writeLine(line string) error { - n, err := io.WriteString(c.TCPConn, line+"\n") - _ = n +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 *connection) writePair(name, value string) error { +func (c *client) writePair(name, value string) error { return c.writeLine(fmt.Sprintf("%s: %s", name, value)) } -func (c *connection) handle(ctx context.Context) error { - defer c.Close() +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) - lines := newLineReader(bufio.NewReader(c.TCPConn)) + for { + cmd, args, err := c.nextCmd() + if err != nil { + if errors.Is(err, io.EOF) { + return nil + } - err := c.handleCmd(lines) - if err != nil { - var errRsp *errorResponse - if errors.As(err, &errRsp) { - c.writeLine(errRsp.String()) - return nil + return err } - 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")) + } - return c.writeLine("OK") -} + var errRsp *errorResponse + if errors.As(err, &errRsp) { + c.writeLine(errRsp.String()) + return nil + } -func (c *connection) handleCmd(lines *lineReader) error { - cmdIdx, line, err := lines.Next() - if err != nil { - return fmt.Errorf("could not read command %d: %w", cmdIdx, err) + return err + } + + err = c.writeLine("OK") + if err != nil { + return err + } } +} - args := newArgParser(line) +type cmdHandler func(c *client, args *argParser) error - _, cmd, ok := args.Next() - if !ok { - return errors.New("empty command") - } +var cmdHandlers map[string]cmdHandler - return c.doCmd(0, cmd, args, lines) +//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 (c *connection) doCmd(idx uint, name string, args *argParser, lines *lineReader) error { - fmt.Println("->", name) +func doCmd(c *client, name string, args *argParser) error { + fmt.Println("->", args.line) - switch name { - case "command_list_ok_begin": - return c.doCmdListOkBegin(args, lines) - case "command_list_end": - return c.doCmdListEnd(args) - case "currentsong": - return c.doCurrentSong(args) - case "status": - return c.doStatus(args) + handler, ok := cmdHandlers[name] + if !ok { + return c.newErr(ackErrorNotList, fmt.Errorf("unknown command: %s", name)) } - return newError(ackErrorNotList, idx, name, fmt.Errorf("unknown command: %s", name)) + return handler(c, args) } // parseArgs returns an array of values matching names, or an error. -func (c *connection) parseArgs(args *argParser, names ...string) ([]string, 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() + val, ok := args.Next() if !ok { - idx := uint(0) // FIXME - name := "" // FIXME - return nil, newError(ackErrorArg, idx, name, - fmt.Errorf("got only %d args, missing: %s", i+1, names[i:])) + 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() + extra, ok := args.Next() if ok { - idx := uint(0) // FIXME - name := "" // FIXME - return nil, newError(ackErrorArg, idx, name, - fmt.Errorf("too many args, first extra value: %s", extra)) + return nil, c.newErr(ackErrorArg, fmt.Errorf("too many args, first extra value: %s", extra)) } return values, nil } -func (c *connection) doCmdListOkBegin(args *argParser, lines *lineReader) error { - if _, err := c.parseArgs(args); err != 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 { - err := c.handleCmd(lines) + 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 @@ -180,28 +231,30 @@ func (c *connection) doCmdListOkBegin(args *argParser, lines *lineReader) error return err } - c.writeLine("list_OK") + if sendOk { + c.writeLine("list_OK") + } } } -func (c *connection) doCmdListEnd(args *argParser) error { - if _, err := c.parseArgs(args); err != nil { +func doCmdListEnd(c *client, args *argParser) error { + if _, err := parseArgs(c, args); err != nil { return err } return errCmdListEnd } -func (c *connection) doCurrentSong(args *argParser) error { - if _, err := c.parseArgs(args); err != nil { +func doCurrentSong(c *client, args *argParser) error { + if _, err := parseArgs(c, args); err != nil { return err } - if c.jukebox == nil { + if c.srv.jukebox == nil { return nil } - status, err := c.jukebox.GetStatus() + status, err := c.srv.jukebox.GetStatus() if err != nil { return err } @@ -215,17 +268,45 @@ func (c *connection) doCurrentSong(args *argParser) error { return nil } -func (c *connection) doStatus(args *argParser) error { - if _, err := c.parseArgs(args); err != 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.jukebox == nil { + if c.srv.jukebox == nil { c.writePair("state", "stop") return nil } - status, err := c.jukebox.GetStatus() + status, err := c.srv.jukebox.GetStatus() if err != nil { return err }