Add MPD client implementation

This commit is contained in:
Olli 2026-02-26 10:38:04 +01:00
commit 4a11ea5ad0
4 changed files with 1212 additions and 0 deletions

947
internal/client/client.go Normal file
View file

@ -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
}