diff --git a/internal/client/client.go b/internal/client/client.go new file mode 100644 index 0000000..26fb49b --- /dev/null +++ b/internal/client/client.go @@ -0,0 +1,947 @@ +package client + +import ( + "fmt" + "io" + "log" + "net/textproto" + "strconv" + "strings" + "sync" + "time" + + "suruatoel.xyz/mcg/internal/common" +) + +type QueueItem struct { + Command func(*Client) error +} + +type Client struct { + Host string + con *textproto.Conn + runGroup sync.WaitGroup + queue chan QueueItem + idling bool + version string + state string + playlist []*common.PlaylistAlbum + albums map[string]*common.Album + errorCB ErrorCB + connectionCB ConnectionCB + playlistCB PlaylistCB + albumsCB AlbumsCB + statusCB StatusCB + statisticsCB StatisticsCB + outputDevicesCB OutputDevicesCB + albumartCB AlbumartCB + albumartChan chan []byte +} + +type ErrorCB func(string) + +type ConnectionCB func(bool) + +type PlaylistCB func([]*common.PlaylistAlbum) + +type AlbumsCB func(map[string]*common.Album) + +type StatusCB func(string, *common.PlaylistAlbum, uint64, uint64, int64, string, string, string, string) + +type StatisticsCB func(string, string, string, string, string, string) + +type OutputDevicesCB func(devices []*common.OutputDevice) + +type AlbumartCB func(*common.Album, []byte) + +var ( + clientInstance *Client + clientInit sync.Once +) + +func Instance() *Client { + clientInit.Do(func() { + clientInstance = &Client{} + }) + + return clientInstance +} + +func (c *Client) IsConnected() bool { + return (c.con != nil) +} + +func (c *Client) Connect(host string, port int, password string) { + c.Host = host + c.queue = make(chan QueueItem, 300) + c.albumartChan = make(chan []byte) + // Add connect action + c.addAction( + func(client *Client) error { + return client.connect(host, port, password) + }, + ) + + go c.run() +} + +func (c *Client) connect(host string, port int, password string) error { + // Notify listeners + defer func() { + if c.connectionCB != nil { + c.connectionCB(c.con != nil) + } + }() + + // Create socket + con, err := textproto.Dial("tcp", fmt.Sprintf("%s:%d", host, port)) + c.con = con + if err != nil { + return err + } + + // Greeting + greeting, err := c.readLine() + if err != nil { + return err + } + if greeting[0:6] != "OK MPD" { + return textproto.ProtocolError(fmt.Sprintf("Unexpected greeting: %s", greeting)) + } + + // Parse and store server version + c.version = greeting[7:] + + // Add post-connect actions + c.addPostConnectActions(password) + + return nil +} + +func (c *Client) addPostConnectActions(password string) { + c.addAction( + func(client *Client) error { + return client.password(password) + }, + func(client *Client) error { + return client.loadAlbums() + }, + func(client *Client) error { + return client.loadPlaylist() + }, + func(client *Client) error { + return client.getStatus() + }, + func(client *Client) error { + return client.getStatistics() + }, + func(client *Client) error { + return client.getOutputDevices() + }, + ) +} + +func (c *Client) password(password string) error { + if password != "" { + reader := &emptyReader{} + return c.cmd(reader, "password", password) + } + + return nil +} + +func (c *Client) Disconnect() { + c.addDisconnectActions() + + close(c.queue) + close(c.albumartChan) + c.runGroup.Wait() +} + +func (c *Client) addDisconnectActions() { + c.addAction( + func(client *Client) error { + return client.disconnect() + }, + ) +} + +func (c *Client) disconnect() error { + // Stop current idle process + c.noIdle() + + // Close connection + err := c.con.Close() + if err != nil { + return err + } + c.con = nil + + // Notify callback + if c.connectionCB != nil { + c.connectionCB(false) + } + + return nil +} + +func (c *Client) run() { + c.runGroup.Add(1) + defer func() { + c.runGroup.Done() + }() + + for action := range c.queue { + err := action.Command(c) + if err != nil && c.errorCB != nil { + c.errorCB(fmt.Sprintf("Error running action: %s\n", err)) + } + } +} + +func (c *Client) Version() string { + return c.version +} + +func (c *Client) ReceiveError(callback ErrorCB) { + c.errorCB = callback +} + +func (c *Client) RegisterConnection(callback ConnectionCB) { + c.connectionCB = callback +} + +func (c *Client) ReceivePlaylist(callback PlaylistCB) { + c.playlistCB = callback +} + +func (c *Client) ReceiveAlbums(callback AlbumsCB) { + c.albumsCB = callback +} + +func (c *Client) ReceiveStatus(callback StatusCB) { + c.statusCB = callback +} + +func (c *Client) ReceiveStatistics(callback StatisticsCB) { + c.statisticsCB = callback +} + +func (c *Client) ReceiveOutputDevices(callback OutputDevicesCB) { + c.outputDevicesCB = callback +} + +func (c *Client) ReceiveAlbumart(callback AlbumartCB) { + c.albumartCB = callback +} + +func (c *Client) loadPlaylist() error { + c.playlist = []*common.PlaylistAlbum{} + + playlistReader := &listMapReader{Delimiter: "file"} + playlistErr := c.cmd(playlistReader, "playlistinfo") + if playlistErr != nil { + return playlistErr + } + + for _, songData := range playlistReader.DictList() { + song := c.extractPlaylistSong(songData) + album := c.extractPlaylistAlbum(songData) + if len(c.playlist) == 0 || c.playlist[len(c.playlist)-1].ID() != album.ID() { + c.playlist = append(c.playlist, album) + } else { + album = c.playlist[len(c.playlist)-1] + } + if song != nil { + album.AddSong(song) + } + } + + if c.playlistCB != nil { + c.playlistCB(c.playlist) + } + + return nil +} + +func (c *Client) loadAlbums() error { + // self._callback(Client.SIGNAL_INIT_ALBUMS) + c.albums = make(map[string]*common.Album) + + albumReader := &listMapReader{Delimiter: "Album"} + albumErr := c.cmd(albumReader, "list album") + if albumErr != nil { + return albumErr + } + for _, entry := range albumReader.DictList() { + // self._callback(Client.SIGNAL_PULSE_ALBUMS) + + // Album + album := c.extractAlbum(entry) + if album != nil { + // Songs + songReader := &listMapReader{Delimiter: "file"} + songErr := c.cmd(songReader, "find album", album.Title()) + if songErr != nil { + return songErr + } + for _, songData := range songReader.DictList() { + song := c.extractSong(songData) + if song != nil { + album.AddSong(song) + } + } + + c.albums[album.ID()] = album + } + } + + if c.albumsCB != nil { + c.albumsCB(c.albums) + } + + return nil +} + +func (c *Client) getStatus() error { + statusReader := &mapReader{} + statusErr := c.cmd(statusReader, "status") + if statusErr != nil { + return statusErr + } + + status := statusReader.Dict() + // State + c.state = status["state"] + // Time + var parseErr error + time := uint64(0) + if timeStr, timeSet := status["time"]; timeSet { + time, parseErr = strconv.ParseUint(strings.Split(timeStr, ":")[0], 10, 64) + if parseErr != nil { + return newValueParseError("time", timeStr) + } + } + // Volume + volume := int64(-1) + if volumeStr, volumeSet := status["volume"]; volumeSet { + volume, parseErr = strconv.ParseInt(volumeStr, 10, 64) + if parseErr != nil { + return newValueParseError("volume", volumeStr) + } + } + // Error + errorMessage := status["error"] + + // Album information + file := "" + var album *common.PlaylistAlbum + pos := uint64(0) + songReader := &mapReader{} + songErr := c.cmd(songReader, "currentsong") + if songErr != nil { + return songErr + } + songData := songReader.Dict() + if len(songData) > 0 { + // File + file = songData["file"] + // Song + song := c.extractPlaylistSong(songData) + if song != nil { + // Album + album = c.extractPlaylistAlbum(songData) + pos = song.Pos() + for _, palbum := range c.playlist { + if palbum.ID() == album.ID() && uint64(len(palbum.Songs())) >= pos { + album = palbum + break + } + pos -= uint64(len(palbum.Songs())) + } + } + } + // Audio + audio := status["audio"] + // Bitrate + bitrate := status["bitrate"] + + if c.statusCB != nil { + c.statusCB(c.state, album, pos, time, volume, file, audio, bitrate, errorMessage) + } + + return nil +} + +func (c *Client) getStatistics() error { + statisticsReader := &mapReader{} + statisticsErr := c.cmd(statisticsReader, "stats") + if statisticsErr != nil { + return statisticsErr + } + + stats := statisticsReader.Dict() + artists := stats["artists"] + albums := stats["albums"] + songs := stats["songs"] + dbPlaytime := stats["db_playtime"] + playtime := stats["playtime"] + uptime := stats["uptime"] + + if c.statisticsCB != nil { + c.statisticsCB(artists, albums, songs, dbPlaytime, playtime, uptime) + } + + return nil +} + +func (c *Client) getOutputDevices() error { + devicesReader := &listMapReader{Delimiter: "outputid"} + devicesErr := c.cmd(devicesReader, "outputs") + if devicesErr != nil { + return devicesErr + } + + devices := []*common.OutputDevice{} + for _, deviceMap := range devicesReader.DictList() { + device := common.NewOutputDevice( + deviceMap["outputid"], + deviceMap["outputname"], + ) + + enabledString := deviceMap["outputenabled"] + if enabledString != "" { + enabled, enabledErr := strconv.ParseBool(enabledString) + if enabledErr == nil { + device.Enabled = enabled + } else { + log.Println("failed to parse status of output device", device.ID, device.Name) + } + } + + devices = append(devices, device) + } + + if c.outputDevicesCB != nil { + c.outputDevicesCB(devices) + } + + return nil +} + +func (c *Client) SetOutputDeviceState(device *common.OutputDevice, enable bool) { + c.addAction(func(client *Client) error { + return client.setOutputDeviceState(device, enable) + }) +} + +func (c *Client) setOutputDeviceState(device *common.OutputDevice, enable bool) error { + reader := &emptyReader{} + if enable { + return c.cmd(reader, "enableoutput", device.ID) + } + return c.cmd(reader, "disableoutput", device.ID) +} + +func (c *Client) PlayPause() { + c.addAction(func(client *Client) error { + return client.playPause() + }) +} + +func (c *Client) playPause() error { + reader := &emptyReader{} + switch c.state { + case "play": + return c.cmd(reader, "pause") + default: + return c.cmd(reader, "play") + } +} + +func (c *Client) PlayAlbum(album *common.Album) { + c.addAction(func(client *Client) error { + return client.playAlbum(album) + }) +} + +func (c *Client) playAlbum(album *common.Album) error { + songIDs, err := c.queueAlbum(album) + if err != nil { + return err + } + + if len(songIDs) > 0 { + reader := &emptyReader{} + return c.cmd(reader, "playid", songIDs[0]) + } + + return nil +} + +func (c *Client) QueueAlbum(album *common.Album) { + c.addAction(func(client *Client) error { + _, err := client.queueAlbum(album) + return err + }) +} + +func (c *Client) queueAlbum(album *common.Album) ([]string, error) { + songIDs := []string{} + for _, song := range album.Songs() { + reader := &mapReader{} + addErr := c.cmd(reader, "addid", song.File()) + if addErr != nil { + return songIDs, addErr + } + + addData := reader.Dict() + songID, found := addData["Id"] + if found { + songIDs = append(songIDs, songID) + } + } + + return songIDs, nil +} + +func (c *Client) QueueAlbums(albums []*common.Album) { + c.addAction(func(client *Client) error { + return client.queueAlbums(albums) + }) +} + +func (c *Client) queueAlbums(albums []*common.Album) error { + for _, album := range albums { + _, queueErr := c.queueAlbum(album) + if queueErr != nil { + return queueErr + } + } + + return nil +} + +func (c *Client) PlayQueuedAlbum(album *common.PlaylistAlbum) { + c.addAction(func(client *Client) error { + return client.playQueuedAlbum(album) + }) +} + +func (c *Client) playQueuedAlbum(album *common.PlaylistAlbum) error { + if len(album.Songs()) <= 0 { + return nil + } + + reader := &emptyReader{} + return c.cmd(reader, "playid", album.Songs()[0].ID()) +} + +func (c *Client) UnqueueAlbum(album *common.PlaylistAlbum) { + c.addAction(func(client *Client) error { + return client.unqueueAlbum(album) + }) +} + +func (c *Client) unqueueAlbum(album *common.PlaylistAlbum) error { + commands := []listCommand{} + for _, song := range album.Songs() { + command := newListCommand("deleteid", song.ID()) + commands = append(commands, command) + } + + return c.cmdList(commands) +} + +func (c *Client) UnqueueAlbums(albums []*common.PlaylistAlbum) { + c.addAction(func(client *Client) error { + return client.unqueueAlbums(albums) + }) +} + +func (c *Client) unqueueAlbums(albums []*common.PlaylistAlbum) error { + commands := []listCommand{} + for _, album := range albums { + for _, song := range album.Songs() { + command := newListCommand("deleteid", song.ID()) + commands = append(commands, command) + } + } + + return c.cmdList(commands) +} + +func (c *Client) Seek(pos int, time uint64) { + c.addAction(func(client *Client) error { + return client.seek(pos, time) + }) +} + +func (c *Client) seek(pos int, time uint64) error { + reader := &emptyReader{} + return c.cmd(reader, "seek", strconv.Itoa(pos), strconv.FormatUint(time, 10)) +} + +func (c *Client) SetVolume(volume int) { + c.addAction(func(client *Client) error { + return client.setVolume(volume) + }) +} + +func (c *Client) setVolume(volume int) error { + reader := &emptyReader{} + return c.cmd(reader, "setvol", strconv.Itoa(volume)) +} + +func (c *Client) LoadAlbumart(album *common.Album) { + c.addAction(func(client *Client) error { + return client.loadAlbumart(album) + }) +} + +func (c *Client) LoadAlbumartNow(album *common.Album) []byte { + c.addAction(func(client *Client) error { + return client.loadAlbumartNow(album) + }) + + return <-c.albumartChan +} + +func (c *Client) loadAlbumart(album *common.Album) error { + reader := &binaryReader{} + reader.size = 1 + reader.offset = 0 + + if c.albumartCB != nil { + // We need to wrap this in an anonymous function to make sure the call to reader.Binary() is deferred as well + defer func() { + c.albumartCB(album, reader.Binary()) + }() + } + + if len(album.Songs()) > 0 { + return c.cmd(reader, "albumart", album.Songs()[0].File(), "0") + } + + return nil +} + +func (c *Client) loadAlbumartNow(album *common.Album) error { + reader := &binaryReader{} + reader.size = 1 + reader.offset = 0 + + defer func() { + c.albumartChan <- reader.Binary() + }() + + if len(album.Songs()) > 0 { + return c.cmd(reader, "albumart", album.Songs()[0].File(), "0") + } + + return nil +} + +func (c *Client) ClearPlaylist() { + c.addAction(func(client *Client) error { + return client.clearPlaylist() + }) +} + +func (c *Client) clearPlaylist() error { + reader := &emptyReader{} + return c.cmd(reader, "clear") +} + +func (c *Client) idle() error { + reader := &listReader{ + key: "changed", + } + + c.idling = true + err := c.cmd(reader, "idle") + c.idling = false + if err != nil { + return err + } + + for _, subsystem := range reader.List() { + switch subsystem { + case "player": + c.addAction( + func(client *Client) error { + return client.loadPlaylist() + }, + func(client *Client) error { + return client.getStatus() + }, + ) + case "mixer": + fallthrough + case "output": + c.addAction(func(client *Client) error { + return client.getStatus() + }) + case "playlist": + c.addAction(func(client *Client) error { + return client.loadPlaylist() + }) + case "database": + fallthrough + case "update": + c.addAction( + func(client *Client) error { + return client.loadAlbums() + }, + func(client *Client) error { + return client.loadPlaylist() + }, + func(client *Client) error { + return client.getStatus() + }, + ) + } + } + + return nil +} + +func (c *Client) noIdle() error { + id, err := c.write("noidle") + if err != nil { + return err + } + + c.con.StartResponse(id) + defer c.con.EndResponse(id) + + return nil +} + +func (c *Client) addAction(commands ...func(*Client) error) { + for _, command := range commands { + c.queue <- QueueItem{ + command, + } + } + c.queue <- QueueItem{ + func(client *Client) error { + if client.con != nil && len(client.queue) == 0 { + return client.idle() + } + return nil + }, + } + if c.idling { + c.noIdle() + } +} + +func (c *Client) cmd(reader reader, action string, args ...string) error { + readingComplete := false + for !readingComplete { + command := c.constructCommand(action, args...) + id, err := c.write(command) + if err != nil { + return textproto.ProtocolError(fmt.Sprintf("failed to write command \"%s\": %s", action, err)) + } + + complete, newArgs, err := c.readData(id, reader, args) + if err != nil { + return textproto.ProtocolError(fmt.Sprintf("failed to read response for command \"%s\": %s", action, err)) + } + readingComplete = complete + args = newArgs + } + + return nil +} + +func (c *Client) cmdList(commands []listCommand) error { + beginReader := &noopReader{} + beginErr := c.cmd(beginReader, "command_list_begin") + if beginErr != nil { + return beginErr + } + + commandReader := &noopReader{} + for _, command := range commands { + commandErr := c.cmd(commandReader, command.Action, command.Args...) + if commandErr != nil { + return commandErr + } + } + + endReader := &emptyReader{} + return c.cmd(endReader, "command_list_end") +} + +func (c *Client) constructCommand(command string, args ...string) string { + if len(args) == 0 { + return command + } + + cleanArgs := c.escapeCommandArguments(args...) + joinedArgs := strings.Join(cleanArgs, "\" \"") + + return fmt.Sprintf("%s \"%s\"", command, joinedArgs) +} + +func (c *Client) escapeCommandArguments(args ...string) []string { + for i, arg := range args { + args[i] = strings.ReplaceAll(arg, "\"", "\\\"") + } + + return args +} + +func (c *Client) write(command string) (uint, error) { + id := c.con.Next() + c.con.StartRequest(id) + defer c.con.EndRequest(id) + + fmt.Fprintf(c.con.W, "%s", command) + c.con.W.WriteByte('\n') + err := c.con.W.Flush() + if err != nil { + return id, err + } + + return id, nil +} + +func (c *Client) readData(id uint, reader reader, args []string) (bool, []string, error) { + c.con.StartResponse(id) + defer c.con.EndResponse(id) + + return reader.ReadData(c, args) +} + +func (c *Client) read() ([]string, error) { + response := []string{} + + for { + line, err := c.readLine() + if err != nil { + return nil, err + } + if line == "OK" { + break + } + response = append(response, line) + } + + return response, nil +} + +func (c *Client) readLine() (string, error) { + line, err := c.con.ReadLine() + if err != nil { + return "", err + } + if strings.HasPrefix(line, "ACK ") { + return "", textproto.ProtocolError(fmt.Sprintf("Unexpected line: %s", line[4:])) + } + + return line, nil +} + +func (c *Client) readBytes(bytes []byte) error { + n, err := io.ReadFull(c.con.R, bytes) + if err != nil { + return err + } + if n != len(bytes) { + return textproto.ProtocolError(fmt.Sprintf("Wrong byte count: %d instead of %d", n, len(bytes))) + } + + return nil +} + +func (c *Client) extractAlbum(song map[string]string) *common.Album { + albumTitle := "Various" + if title, titleSet := song["Album"]; titleSet { + albumTitle = title + } + album := common.NewAlbum(albumTitle) + + if albumLookup, albumExists := c.albums[album.ID()]; albumExists { + return albumLookup + } + + return album +} + +func (c *Client) extractPlaylistAlbum(song map[string]string) *common.PlaylistAlbum { + albumTitle := "Various" + if title, titleSet := song["Album"]; titleSet { + albumTitle = title + } + album := common.NewAlbum(albumTitle) + + if albumLookup, albumExists := c.albums[album.ID()]; albumExists { + album = albumLookup + } + + playlistAlbum := common.NewPlaylistAlbum(album) + + return playlistAlbum +} + +func (c *Client) extractSong(data map[string]string) *common.Song { + if artist, artistSet := data["Artist"]; artistSet { + if title, titleSet := data["Title"]; titleSet { + if file, fileSet := data["file"]; fileSet { + song := common.NewSong(artist, title, file) + + if track, isSet := data["Track"]; isSet { + song.SetTrack(track) + } + if timeString, isSet := data["Time"]; isSet { + time, timeErr := strconv.ParseUint(timeString, 10, 64) + if timeErr == nil { + song.SetLength(time) + } else { + log.Println("failed to parse \"Time\" field of song file", file) + } + } + if date, isSet := data["Date"]; isSet { + song.SetDate(date) + } + if albumartist, isSet := data["Albumartist"]; isSet { + song.AddAlbumartist(albumartist) + } + if lastModifiedString, isSet := data["last-modified"]; isSet { + lastModified, dateErr := time.Parse("2006-01-02T15:04:05-0700", lastModifiedString) + if dateErr == nil { + song.SetLastModified(lastModified) + } else { + log.Println("failed to parse \"last-modified\" field of song file", file) + } + } + + return song + } + } + } + + return nil +} + +func (c *Client) extractPlaylistSong(data map[string]string) *common.PlaylistSong { + song := c.extractSong(data) + if song != nil { + if id, idSet := data["Id"]; idSet { + if posString, posSet := data["Pos"]; posSet { + pos, posErr := strconv.ParseUint(posString, 10, 64) + if posErr != nil { + log.Println("failed to parse \"Pos\" field of playlist song") + } + + return common.NewPlaylistSong(song, id, pos) + } + } + } + + return nil +} diff --git a/internal/client/command.go b/internal/client/command.go new file mode 100644 index 0000000..a584e9f --- /dev/null +++ b/internal/client/command.go @@ -0,0 +1,10 @@ +package client + +type listCommand struct { + Action string + Args []string +} + +func newListCommand(action string, args ...string) listCommand { + return listCommand{action, args} +} diff --git a/internal/client/errors.go b/internal/client/errors.go new file mode 100644 index 0000000..345dc11 --- /dev/null +++ b/internal/client/errors.go @@ -0,0 +1,19 @@ +package client + +import "fmt" + +type ValueParseErr struct { + field string + value string +} + +func newValueParseError(field string, value string) ValueParseErr { + return ValueParseErr{ + field: field, + value: value, + } +} + +func (e ValueParseErr) Error() string { + return fmt.Sprintf("failed to parse %s value: %s", e.field, e.value) +} diff --git a/internal/client/reader.go b/internal/client/reader.go new file mode 100644 index 0000000..27fa821 --- /dev/null +++ b/internal/client/reader.go @@ -0,0 +1,236 @@ +package client + +import ( + "fmt" + "net/textproto" + "strconv" + "strings" +) + +type reader interface { + ReadData(client *Client, args []string) (bool, []string, error) +} + +type noopReader struct{} + +func (r *noopReader) ReadData(client *Client, args []string) (bool, []string, error) { + return true, args, nil +} + +type emptyReader struct{} + +func (r *emptyReader) ReadData(client *Client, args []string) (bool, []string, error) { + _, err := client.read() + + return true, args, err +} + +type listReader struct { + key string + data []string +} + +func (r *listReader) ReadData(client *Client, args []string) (bool, []string, error) { + data, err := client.read() + if err != nil { + return true, args, err + } + + r.data = []string{} + for _, line := range data { + if !strings.HasPrefix(line, r.key+": ") { + return true, args, textproto.ProtocolError(fmt.Sprintf("Unexpected line: %s", line)) + } + + r.data = append(r.data, line[len(r.key)+2:]) + } + + return true, args, nil +} + +func (r *listReader) List() []string { + return r.data +} + +type mapReader struct { + data map[string]string +} + +func (r *mapReader) ReadData(client *Client, args []string) (bool, []string, error) { + data, err := client.read() + if err != nil { + return true, args, err + } + + r.data = make(map[string]string) + for _, line := range data { + parts := strings.Split(line, ": ") + if len(parts) < 2 { + return true, args, textproto.ProtocolError(fmt.Sprintf("Unexpected line: %s", line)) + } + r.data[parts[0]] = strings.Join(parts[1:], ": ") + } + + return true, args, nil +} + +func (r *mapReader) Dict() map[string]string { + return r.data +} + +type listMapReader struct { + Delimiter string + data []map[string]string +} + +func (r *listMapReader) ReadData(client *Client, args []string) (bool, []string, error) { + data, err := client.read() + if err != nil { + return true, args, err + } + + r.data = nil + var entry map[string]string + for _, line := range data { + parts := strings.Split(line, ": ") + if len(parts) < 2 { + return true, args, textproto.ProtocolError(fmt.Sprintf("Unexpected line: %s", line)) + } + + if parts[0] == r.Delimiter { + if entry != nil { + r.data = append(r.data, entry) + } + entry = make(map[string]string) + } + entry[parts[0]] = strings.Join(parts[1:], ": ") + } + if entry != nil { + r.data = append(r.data, entry) + } + + return true, args, nil +} + +func (r *listMapReader) DictList() []map[string]string { + return r.data +} + +type binaryReader struct { + data []byte + size int + offset int +} + +func (r *binaryReader) ReadData(client *Client, args []string) (bool, []string, error) { + firstLine, dataComplete, firstLineErr := r.readFirstLine(client) + if firstLineErr != nil { + return true, args, firstLineErr + } + if dataComplete { + return true, args, nil + } + + totalSize, sizeErr := r.readSize(firstLine) + if sizeErr != nil { + return true, args, sizeErr + } + r.size = totalSize + + currentSize, bytesCountErr := r.readBytesCount(client) + if bytesCountErr != nil { + return true, args, bytesCountErr + } + + r.initData(totalSize) + bytesErr := r.readBytes(client, currentSize) + if bytesErr != nil { + return true, args, bytesErr + } + + _, lineBreakErr := client.readLine() + if lineBreakErr != nil { + return true, args, lineBreakErr + } + _, lineCompletionErr := client.readLine() + if lineCompletionErr != nil { + return true, args, lineCompletionErr + } + + complete := (r.offset >= r.size) + newArgs := r.updateOffsetArgument(args) + + return complete, newArgs, nil +} + +func (r *binaryReader) readFirstLine(client *Client) (line string, complete bool, err error) { + line, lineErr := client.readLine() + if lineErr != nil { + return "", true, lineErr + } + + // OK here means the binary data is complete + if line == "OK" { + return "", true, nil + } + + return line, false, nil +} + +func (r *binaryReader) readSize(line string) (int, error) { + if !strings.HasPrefix(line, "size: ") { + return 0, textproto.ProtocolError(fmt.Sprintf("Unexpected line: %s", line)) + } + + parsedSize, err := strconv.Atoi(line[6:]) + if err != nil { + return 0, textproto.ProtocolError(fmt.Sprintf("Invalid size: %s", line[6:])) + } + + return parsedSize, nil +} + +func (r *binaryReader) readBytesCount(client *Client) (int, error) { + line, lineErr := client.readLine() + if lineErr != nil { + return 0, lineErr + } + + if !strings.HasPrefix(line, "binary: ") { + return 0, textproto.ProtocolError(fmt.Sprintf("Unexpected line: %s", line)) + } + + count, countErr := strconv.Atoi(line[8:]) + if countErr != nil { + return 0, textproto.ProtocolError(fmt.Sprintf("Invalid binary: %s", line[8:])) + } + + return count, nil +} + +func (r *binaryReader) initData(size int) { + if r.data == nil { + r.data = make([]byte, size) + } +} + +func (r *binaryReader) readBytes(client *Client, size int) error { + bytesError := client.readBytes(r.data[r.offset : r.offset+size]) + if bytesError != nil { + return bytesError + } + + r.offset += size + + return nil +} + +func (r *binaryReader) updateOffsetArgument(args []string) []string { + args[len(args)-1] = strconv.Itoa(r.offset) + + return args +} + +func (r *binaryReader) Binary() []byte { + return r.data +}