diff --git a/.gitignore b/.gitignore
index b4a875f..bb58b41 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,4 +12,7 @@
/.gocache
/vcpkg
/vcpkg_installed
-/dist
\ No newline at end of file
+/distgo-librespot
+*.tar.gz
+sedtmp
+.go-librespot
diff --git a/cmd/daemon/controls.go b/cmd/daemon/controls.go
index c239912..b30b9a6 100644
--- a/cmd/daemon/controls.go
+++ b/cmd/daemon/controls.go
@@ -7,7 +7,9 @@ import (
"encoding/hex"
"errors"
"fmt"
+ "io"
"math"
+ "net/http"
"strconv"
"strings"
"time"
@@ -16,11 +18,91 @@ import (
"github.com/devgianlu/go-librespot/mpris"
"github.com/devgianlu/go-librespot/player"
connectpb "github.com/devgianlu/go-librespot/proto/spotify/connectstate"
+ metadatapb "github.com/devgianlu/go-librespot/proto/spotify/metadata"
playerpb "github.com/devgianlu/go-librespot/proto/spotify/player"
"github.com/devgianlu/go-librespot/tracks"
"google.golang.org/protobuf/proto"
)
+func (p *AppPlayer) extractMetadataFromStream(stream *player.Stream) player.TrackUpdateInfo {
+ var title, artist, album, trackID string
+ var duration time.Duration
+ var artworkURL string
+ var artworkData []byte
+
+ if stream == nil || stream.Media == nil {
+ return player.TrackUpdateInfo{}
+ }
+
+ media := stream.Media
+
+ // Handle tracks
+ if media.IsTrack() {
+ track := media.Track()
+ if track != nil {
+ if track.Name != nil {
+ title = *track.Name
+ }
+ trackID = fmt.Sprintf("%x", track.Gid)
+ if track.Duration != nil {
+ duration = time.Duration(*track.Duration) * time.Millisecond
+ }
+
+ // Get first artist
+ if len(track.Artist) > 0 && track.Artist[0].Name != nil {
+ artist = *track.Artist[0].Name
+ }
+
+ // Get album and artwork
+ if track.Album != nil {
+ if track.Album.Name != nil {
+ album = *track.Album.Name
+ }
+
+ artworkURL = p.getAlbumArtworkURL(track.Album)
+ if artworkURL != "" {
+ artworkData = p.downloadArtwork(artworkURL)
+ }
+ }
+ }
+ } else if media.IsEpisode() {
+ // Handle podcast episodes
+ episode := media.Episode()
+ if episode != nil {
+ if episode.Name != nil {
+ title = *episode.Name
+ }
+ trackID = fmt.Sprintf("%x", episode.Gid)
+ if episode.Duration != nil {
+ duration = time.Duration(*episode.Duration) * time.Millisecond
+ }
+
+ // For episodes, use show name as artist
+ if episode.Show != nil {
+ if episode.Show.Name != nil {
+ artist = *episode.Show.Name
+ }
+
+ artworkURL = p.getShowArtworkURL(episode.Show)
+ if artworkURL != "" {
+ artworkData = p.downloadArtwork(artworkURL)
+ }
+ }
+ album = "Podcast" // Generic album name for episodes
+ }
+ }
+
+ return player.TrackUpdateInfo{
+ Title: title,
+ Artist: artist,
+ Album: album,
+ TrackID: trackID,
+ Duration: duration,
+ ArtworkURL: artworkURL,
+ ArtworkData: artworkData,
+ }
+}
+
func (p *AppPlayer) prefetchNext(ctx context.Context) {
// Limit ourselves to 30 seconds for prefetching
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
@@ -145,6 +227,11 @@ func (p *AppPlayer) handlePlayerEvent(ctx context.Context, ev *player.Event) {
p.sess.Events().OnPlayerResume(p.primaryStream, p.state.trackPosition())
+ p.UpdatePlayingState(true)
+
+ // Add this line to update position on resume
+ p.UpdatePosition(time.Duration(p.player.PositionMs()) * time.Millisecond)
+
p.emitMprisUpdate(mpris.Playing)
p.app.server.Emit(&ApiEvent{
@@ -171,6 +258,8 @@ func (p *AppPlayer) handlePlayerEvent(ctx context.Context, ev *player.Event) {
p.state.trackPosition(),
)
+ p.UpdatePlayingState(false)
+
p.emitMprisUpdate(mpris.Paused)
p.app.server.Emit(&ApiEvent{
@@ -184,6 +273,8 @@ func (p *AppPlayer) handlePlayerEvent(ctx context.Context, ev *player.Event) {
case player.EventTypeNotPlaying:
p.sess.Events().OnPlayerEnd(p.primaryStream, p.state.trackPosition())
+ p.UpdatePlayingState(false)
+
p.app.server.Emit(&ApiEvent{
Type: ApiEventTypeNotPlaying,
Data: ApiEventDataNotPlaying{
@@ -209,6 +300,9 @@ func (p *AppPlayer) handlePlayerEvent(ctx context.Context, ev *player.Event) {
p.emitMprisUpdate(mpris.Stopped)
}
case player.EventTypeStop:
+
+ p.UpdatePlayingState(false)
+
p.app.server.Emit(&ApiEvent{
Type: ApiEventTypeStopped,
Data: ApiEventDataStopped{
@@ -342,6 +436,19 @@ func (p *AppPlayer) loadCurrentTrack(ctx context.Context, paused, drop bool) err
p.sess.Events().PostPrimaryStreamLoad(p.primaryStream, paused)
+ if p.primaryStream != nil {
+ trackPosition := p.state.trackPosition() // Get the current position
+ trackInfo := p.extractMetadataFromStream(p.primaryStream)
+ p.app.log.Debugf("Sending metadata: %s by %s (artwork: %d bytes, position: %dms)", trackInfo.Title, trackInfo.Artist, len(trackInfo.ArtworkData), trackPosition)
+
+ // First update the track (without position to avoid breaking other callers)
+ trackInfo.Playing = !paused
+ p.UpdateTrack(trackInfo)
+
+ // Then immediately update the position
+ p.UpdatePosition(time.Duration(trackPosition) * time.Millisecond)
+ }
+
p.app.log.WithField("uri", spotId.Uri()).
Infof("loaded %s %s (paused: %t, position: %dms, duration: %dms, prefetched: %t)", spotId.Type(),
strconv.QuoteToGraphic(p.primaryStream.Media.Name()), paused, trackPosition, p.primaryStream.Media.Duration(),
@@ -519,6 +626,8 @@ func (p *AppPlayer) seek(ctx context.Context, position int64) error {
p.sess.Events().OnPlayerSeek(p.primaryStream, oldPosition, position)
+ p.UpdatePosition(time.Duration(position) * time.Millisecond)
+
p.app.mpris.EmitSeekUpdate(
mpris.SeekState{
PositionMs: position,
@@ -723,6 +832,9 @@ func (p *AppPlayer) updateVolume(newVal uint32) {
}
p.volumeUpdate <- float32(newVal) / player.MaxStateVolume
+
+ volumePercent := int((float64(newVal) / player.MaxStateVolume) * 100)
+ p.UpdateVolume(volumePercent)
}
// Send notification that the volume changed.
@@ -746,6 +858,67 @@ func (p *AppPlayer) volumeUpdated(ctx context.Context) {
})
}
+func (p *AppPlayer) getAlbumArtworkURL(album *metadatapb.Album) string {
+ if album == nil || album.CoverGroup == nil || len(album.CoverGroup.Image) == 0 {
+ return ""
+ }
+
+ // Get the best quality artwork (usually the last image)
+ images := album.CoverGroup.Image
+ if len(images) > 0 {
+ bestImage := images[len(images)-1]
+ if bestImage != nil && len(bestImage.FileId) > 0 {
+ return fmt.Sprintf("https://i.scdn.co/image/%x", bestImage.FileId)
+ }
+ }
+
+ return ""
+}
+
+func (p *AppPlayer) getShowArtworkURL(show *metadatapb.Show) string {
+ if show == nil || show.CoverImage == nil || len(show.CoverImage.Image) == 0 {
+ return ""
+ }
+
+ // Get the best quality image (usually the last one)
+ images := show.CoverImage.Image
+ if len(images) > 0 {
+ bestImage := images[len(images)-1]
+ if bestImage != nil && len(bestImage.FileId) > 0 {
+ return fmt.Sprintf("https://i.scdn.co/image/%x", bestImage.FileId)
+ }
+ }
+
+ return ""
+}
+
+// Add downloadArtwork method:
+func (p *AppPlayer) downloadArtwork(url string) []byte {
+ if url == "" {
+ return nil
+ }
+
+ // Download with timeout
+ client := &http.Client{Timeout: 5 * time.Second}
+ resp, err := client.Get(url)
+ if err != nil {
+ p.app.log.WithError(err).Debugf("failed downloading artwork")
+ return nil
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 200 {
+ return nil
+ }
+
+ // Read ALL the data using io.ReadAll (properly handles chunked reading)
+ data, err := io.ReadAll(io.LimitReader(resp.Body, 1024*1024)) // Still limit to 1MB
+ if err != nil {
+ p.app.log.WithError(err).Debugf("failed reading artwork data")
+ return nil
+ }
+ return data
+}
func (p *AppPlayer) stopPlayback(ctx context.Context) error {
p.player.Stop()
p.primaryStream = nil
@@ -767,4 +940,5 @@ func (p *AppPlayer) stopPlayback(ctx context.Context) error {
})
return nil
+
}
diff --git a/cmd/daemon/main.go b/cmd/daemon/main.go
index f56da42..e7c8a51 100644
--- a/cmd/daemon/main.go
+++ b/cmd/daemon/main.go
@@ -16,6 +16,7 @@ import (
"github.com/devgianlu/go-librespot/mpris"
"github.com/devgianlu/go-librespot/apresolve"
+ "github.com/devgianlu/go-librespot/metadata"
"github.com/devgianlu/go-librespot/player"
devicespb "github.com/devgianlu/go-librespot/proto/spotify/connectstate/devices"
"github.com/devgianlu/go-librespot/session"
@@ -122,6 +123,14 @@ func (app *App) newAppPlayer(ctx context.Context, creds any) (_ *AppPlayer, err
volumeUpdate: make(chan float32, 1),
}
+ appPlayer.metadataPlayer = metadata.NewPlayerMetadata(app.log, metadata.MetadataPipeConfig{
+ Enabled: app.cfg.MetadataPipe.Enabled,
+ Path: app.cfg.MetadataPipe.Path,
+ Format: app.cfg.MetadataPipe.Format,
+ BufferSize: app.cfg.MetadataPipe.BufferSize,
+ })
+
+ // start a dummy timer for prefetching next media
appPlayer.prefetchTimer = time.NewTimer(math.MaxInt64)
appPlayer.prefetchTimer.Stop()
@@ -167,11 +176,16 @@ func (app *App) newAppPlayer(ctx context.Context, creds any) (_ *AppPlayer, err
AudioOutputPipe: app.cfg.AudioOutputPipe,
AudioOutputPipeFormat: app.cfg.AudioOutputPipeFormat,
+ MetadataCallback: appPlayer, // AppPlayer implements MetadataCallback
},
); err != nil {
return nil, fmt.Errorf("failed initializing player: %w", err)
}
+ if err := appPlayer.metadataPlayer.Start(); err != nil {
+ app.log.WithError(err).Warnf("failed to start metadata system")
+ }
+
return appPlayer, nil
}
@@ -425,6 +439,12 @@ type Config struct {
PersistCredentials bool `koanf:"persist_credentials"`
} `koanf:"zeroconf"`
} `koanf:"credentials"`
+ MetadataPipe struct {
+ Enabled bool `koanf:"enabled"`
+ Path string `koanf:"path"`
+ Format string `koanf:"format"`
+ BufferSize int `koanf:"buffer_size"`
+ } `koanf:"metadata_pipe"`
}
func loadConfig(cfg *Config) error {
@@ -478,8 +498,12 @@ func loadConfig(cfg *Config) error {
"volume_steps": 100,
"initial_volume": 100,
- "credentials.type": "zeroconf",
- "server.address": "localhost",
+ "credentials.type": "zeroconf",
+ "server.address": "localhost",
+ "metadata_pipe.enabled": false,
+ "metadata_pipe.path": "/tmp/go-librespot-metadata",
+ "metadata_pipe.format": "dacp",
+ "metadata_pipe.buffer_size": 100,
}, "."), nil)
// load file configuration (if available)
diff --git a/cmd/daemon/player.go b/cmd/daemon/player.go
index 77dd417..872ae54 100644
--- a/cmd/daemon/player.go
+++ b/cmd/daemon/player.go
@@ -22,6 +22,7 @@ import (
librespot "github.com/devgianlu/go-librespot"
"github.com/devgianlu/go-librespot/ap"
"github.com/devgianlu/go-librespot/dealer"
+ "github.com/devgianlu/go-librespot/metadata"
"github.com/devgianlu/go-librespot/player"
connectpb "github.com/devgianlu/go-librespot/proto/spotify/connectstate"
"github.com/devgianlu/go-librespot/session"
@@ -48,7 +49,8 @@ type AppPlayer struct {
primaryStream *player.Stream
secondaryStream *player.Stream
- prefetchTimer *time.Timer
+ prefetchTimer *time.Timer
+ metadataPlayer *metadata.PlayerMetadata
}
func (p *AppPlayer) handleAccesspointPacket(pktType ap.PacketType, payload []byte) error {
@@ -734,3 +736,27 @@ func (p *AppPlayer) Run(ctx context.Context, apiRecv <-chan ApiRequest, mprisRec
}
}
}
+
+func (p *AppPlayer) UpdateTrack(info player.TrackUpdateInfo) {
+ if p.metadataPlayer != nil {
+ p.metadataPlayer.UpdateTrack(info)
+ }
+}
+
+func (p *AppPlayer) UpdatePosition(position time.Duration) {
+ if p.metadataPlayer != nil {
+ p.metadataPlayer.UpdatePosition(position)
+ }
+}
+
+func (p *AppPlayer) UpdateVolume(volume int) {
+ if p.metadataPlayer != nil {
+ p.metadataPlayer.UpdateVolume(volume)
+ }
+}
+
+func (p *AppPlayer) UpdatePlayingState(playing bool) {
+ if p.metadataPlayer != nil {
+ p.metadataPlayer.UpdatePlayingState(playing)
+ }
+}
diff --git a/config_schema.json b/config_schema.json
index 02f6464..7aa0bdf 100644
--- a/config_schema.json
+++ b/config_schema.json
@@ -267,6 +267,34 @@
"description": "Whether autoplay of more songs should be disabled",
"default": false
},
+ "metadata_pipe": {
+ "type": "object",
+ "description": "Metadata pipe configuration for DACP/forked-daapd integration",
+ "properties": {
+ "enabled": {
+ "type": "boolean",
+ "default": false,
+ "description": "Enable metadata pipe output"
+ },
+ "path": {
+ "type": "string",
+ "default": "/tmp/go-librespot-metadata",
+ "description": "Path to the metadata FIFO pipe"
+ },
+ "format": {
+ "type": "string",
+ "enum": ["dacp", "json", "xml"],
+ "default": "dacp",
+ "description": "Output format for metadata (dacp for forked-daapd, json for custom applications)"
+ },
+ "buffer_size": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 1000,
+ "default": 100,
+ "description": "Size of the metadata buffer"
+ }
+ }
"mpris_enabled": {
"type": "boolean",
"description": "Enables MPRIS communication with D-Bus",
diff --git a/metadata/fifo.go b/metadata/fifo.go
new file mode 100644
index 0000000..2589a52
--- /dev/null
+++ b/metadata/fifo.go
@@ -0,0 +1,210 @@
+package metadata
+
+import (
+ "fmt"
+ "os"
+ "sync"
+ "syscall"
+ "time"
+
+ librespot "github.com/devgianlu/go-librespot"
+)
+
+// FIFOManager manages the metadata FIFO pipe
+type FIFOManager struct {
+ log librespot.Logger
+ path string
+ format string
+ bufferSize int
+
+ pipe *os.File
+ mutex sync.RWMutex
+ closed bool
+ buffer chan []byte
+ stopCh chan struct{}
+
+ // Metrics
+ writeCount int64
+ errorCount int64
+ dropCount int64
+}
+
+// NewFIFOManager creates a new FIFO manager
+func NewFIFOManager(log librespot.Logger, path, format string, bufferSize int) *FIFOManager {
+ return &FIFOManager{
+ log: log,
+ path: path,
+ format: format,
+ bufferSize: bufferSize,
+ buffer: make(chan []byte, bufferSize),
+ stopCh: make(chan struct{}),
+ }
+}
+
+// Start initializes and starts the FIFO manager
+func (fm *FIFOManager) Start() error {
+ // Create named pipe if it doesn't exist
+ if err := fm.createFIFO(); err != nil {
+ return fmt.Errorf("failed to create FIFO: %w", err)
+ }
+
+ fm.log.WithField("path", fm.path).WithField("format", fm.format).
+ Infof("metadata FIFO started")
+
+ // Start the writer goroutine
+ go fm.writerLoop()
+
+ return nil
+}
+
+// Stop stops the FIFO manager
+func (fm *FIFOManager) Stop() {
+ fm.mutex.Lock()
+ defer fm.mutex.Unlock()
+
+ if fm.closed {
+ return
+ }
+
+ fm.closed = true
+ close(fm.stopCh)
+
+ if fm.pipe != nil {
+ fm.pipe.Close()
+ }
+
+ // Remove the FIFO file
+ if fm.path != "" {
+ os.Remove(fm.path)
+ }
+
+ fm.log.WithField("writes", fm.writeCount).
+ WithField("errors", fm.errorCount).
+ WithField("drops", fm.dropCount).
+ Infof("metadata FIFO stopped")
+}
+
+// WriteMetadata writes metadata to the FIFO
+func (fm *FIFOManager) WriteMetadata(metadata *TrackMetadata) {
+ fm.mutex.RLock()
+ closed := fm.closed
+ fm.mutex.RUnlock()
+
+ if closed {
+ return
+ }
+
+ var data []byte
+ switch fm.format {
+ case "json":
+ data = metadata.ToJSONFormat()
+ case "xml":
+ data = metadata.ToXMLFormat()
+ default: // "dacp"
+ data = metadata.ToDACPFormat()
+ }
+
+ select {
+ case fm.buffer <- data:
+ // Successfully queued
+ case <-time.After(50 * time.Millisecond):
+ fm.dropCount++
+ // Drop the message if buffer is full
+ }
+}
+
+// createFIFO creates the named pipe
+func (fm *FIFOManager) createFIFO() error {
+ // Remove existing FIFO if it exists
+ os.Remove(fm.path)
+
+ // Create new FIFO
+ err := syscall.Mkfifo(fm.path, 0666)
+ if err != nil {
+ // If FIFO already exists, that is acceptable
+ if err == syscall.EEXIST {
+ return nil
+ }
+ return fmt.Errorf("mkfifo failed: %w", err)
+ }
+
+ return nil
+}
+
+// openFIFO opens the FIFO for writing (non-blocking)
+func (fm *FIFOManager) openFIFO() error {
+ pipe, err := os.OpenFile(fm.path, os.O_WRONLY|syscall.O_NONBLOCK, 0)
+ if err != nil {
+ return fmt.Errorf("failed to open FIFO: %w", err)
+ }
+
+ fm.pipe = pipe
+ return nil
+}
+
+// writerLoop handles writing to the FIFO
+func (fm *FIFOManager) writerLoop() {
+ ticker := time.NewTicker(5 * time.Second)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case data := <-fm.buffer:
+ if err := fm.writeToFIFO(data); err != nil {
+ fm.errorCount++
+ // Only log errors occasionally to avoid spam
+ if fm.errorCount%50 == 1 {
+ fm.log.WithError(err).Debugf("error writing to metadata FIFO")
+ }
+ } else {
+ fm.writeCount++
+ }
+
+ case <-ticker.C:
+ // Periodic check for reader presence
+ fm.checkFIFOConnection()
+
+ case <-fm.stopCh:
+ return
+ }
+ }
+}
+
+// writeToFIFO writes data to the FIFO, handling reconnection
+func (fm *FIFOManager) writeToFIFO(data []byte) error {
+ fm.mutex.Lock()
+ defer fm.mutex.Unlock()
+
+ if fm.pipe == nil {
+ if err := fm.openFIFO(); err != nil {
+ return err
+ }
+ }
+
+ _, err := fm.pipe.Write(data)
+ if err != nil {
+ // Close pipe on error, will reconnect on next write
+ fm.pipe.Close()
+ fm.pipe = nil
+ return err
+ }
+
+ return nil
+}
+
+// checkFIFOConnection checks if the FIFO is still connected
+func (fm *FIFOManager) checkFIFOConnection() {
+ fm.mutex.Lock()
+ defer fm.mutex.Unlock()
+
+ if fm.pipe == nil {
+ return
+ }
+
+ // Try a zero-byte write to check connection
+ _, err := fm.pipe.Write([]byte{})
+ if err != nil {
+ fm.pipe.Close()
+ fm.pipe = nil
+ }
+}
diff --git a/metadata/metadata.go b/metadata/metadata.go
new file mode 100644
index 0000000..1c37fd4
--- /dev/null
+++ b/metadata/metadata.go
@@ -0,0 +1,263 @@
+package metadata
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "time"
+)
+
+// TrackMetadata represents the current track information
+type TrackMetadata struct {
+ Title string `json:"title"`
+ Artist string `json:"artist"`
+ Album string `json:"album"`
+ Duration int64 `json:"duration_ms"`
+ Position int64 `json:"position_ms"`
+ TrackID string `json:"track_id"`
+ Volume int `json:"volume"`
+ Playing bool `json:"playing"`
+ Timestamp time.Time `json:"timestamp"`
+ ArtworkURL string `json:"artwork_url,omitempty"`
+ ArtworkData []byte `json:"artwork_data,omitempty"`
+ SampleRate float32 `json:"sample_rate,omitempty"` // ADD THIS for progress tracking
+}
+
+// NewTrackMetadata creates a new TrackMetadata instance
+func NewTrackMetadata() *TrackMetadata {
+ return &TrackMetadata{
+ Timestamp: time.Now(),
+ SampleRate: 44100, // Default sample rate
+ }
+}
+
+// ToDACPFormat converts metadata to DACP pipe format compatible with forked-daapd
+func (tm *TrackMetadata) ToDACPFormat() []byte {
+ var result []byte
+
+ // DACP format uses key-value pairs with specific encoding
+ if tm.Title != "" {
+ result = append(result, encodeDACPItem("minm", tm.Title)...)
+ }
+ if tm.Artist != "" {
+ result = append(result, encodeDACPItem("asar", tm.Artist)...)
+ }
+ if tm.Album != "" {
+ result = append(result, encodeDACPItem("asal", tm.Album)...)
+ }
+
+ // ADD ARTWORK URL:
+ if tm.ArtworkURL != "" {
+ result = append(result, encodeDACPItem("asul", tm.ArtworkURL)...)
+ }
+
+ // Volume (0-100)
+ result = append(result, encodeDACPInt("cmvo", tm.Volume)...)
+
+ // Playing state: 2 = paused, 3 = playing, 4 = stopped
+ playState := 4 // stopped
+ if tm.Playing {
+ playState = 3 // playing
+ } else if tm.Duration > 0 {
+ playState = 2 // paused
+ }
+ result = append(result, encodeDACPInt("caps", playState)...)
+
+ // Duration in milliseconds
+ if tm.Duration > 0 {
+ result = append(result, encodeDACPInt("astm", int(tm.Duration))...)
+ }
+
+ // Position in milliseconds
+ result = append(result, encodeDACPInt("cant", int(tm.Position))...)
+
+ return result
+}
+
+// ToJSONFormat converts metadata to JSON format
+func (tm *TrackMetadata) ToJSONFormat() []byte {
+ data, _ := json.Marshal(tm)
+ return append(data, '\n')
+}
+
+// ToXMLFormat method with debugging for artwork:
+func (tm *TrackMetadata) ToXMLFormat() []byte {
+ var result []byte
+
+ // Helper function to encode XML metadata item with hex-encoded type/code
+ encodeItem := func(itemType, code, data string) []byte {
+ typeHex := asciiToHex(itemType)
+ codeHex := asciiToHex(code)
+
+ // Get original data length BEFORE encoding
+ originalLength := len(data)
+
+ // Encode data as base64
+ encodedData := base64.StdEncoding.EncodeToString([]byte(data))
+
+ // Use hex format for length (matching the type/code format)
+ lengthHex := fmt.Sprintf("%x", originalLength)
+
+ // Create XML-style item with hex-encoded length
+ item := fmt.Sprintf("- %s
%s%s%s \n",
+ typeHex, codeHex, lengthHex, encodedData)
+
+ return []byte(item)
+ }
+
+ encodeRawData := func(itemType, code string, data []byte) []byte {
+ typeHex := asciiToHex(itemType)
+ codeHex := asciiToHex(code)
+
+ // Encode binary data as base64
+ encodedData := base64.StdEncoding.EncodeToString(data)
+
+
+ // Use the ORIGINAL BINARY LENGTH, with 8-digit hex format
+ lengthHex := fmt.Sprintf("%08x", len(data))
+
+ // Create XML-style item
+ item := fmt.Sprintf("- %s
%s%s%s \n",
+ typeHex, codeHex, lengthHex, encodedData)
+
+ return []byte(item)
+ }
+
+ // Track title
+ if tm.Title != "" {
+ result = append(result, encodeItem("core", "minm", tm.Title)...)
+ }
+
+ // Artist
+ if tm.Artist != "" {
+ result = append(result, encodeItem("core", "asar", tm.Artist)...)
+ }
+
+ // Album
+ if tm.Album != "" {
+ result = append(result, encodeItem("core", "asal", tm.Album)...)
+ }
+
+ // ARTWORK - try different approaches:
+ if len(tm.ArtworkData) > 0 {
+ // Try ssnc/PICT with encoded length
+ result = append(result, encodeRawData("ssnc", "PICT", tm.ArtworkData)...)
+
+ // ALSO try mper/PICT (another common type for artwork)
+ // Uncomment if needed: result = append(result, encodeRawData("mper", "PICT", tm.ArtworkData)...)
+ }
+
+ // Progress tracking (like librespot-java)
+ if tm.Duration > 0 && tm.SampleRate > 0 {
+ currentTime := float64(tm.Position)
+ duration := float64(tm.Duration)
+ sampleRate := float64(tm.SampleRate)
+
+ progressStr := fmt.Sprintf("1/%.0f/%.0f",
+ currentTime*sampleRate/1000.0+1,
+ duration*sampleRate/1000.0+1)
+
+ result = append(result, encodeItem("ssnc", "prgr", progressStr)...)
+ }
+
+ // Playing state
+ playState := "stop"
+ if tm.Playing {
+ playState = "play"
+ } else if tm.Duration > 0 {
+ playState = "pause"
+ }
+ result = append(result, encodeItem("ssnc", "pply", playState)...)
+
+ // Volume (0-100)
+ volumeStr := fmt.Sprintf("%d", tm.Volume)
+ result = append(result, encodeItem("ssnc", "pvol", volumeStr)...)
+
+ // Position (in seconds) - make sure we're using the actual position
+ positionSec := tm.Position / 1000
+ positionStr := fmt.Sprintf("%d", positionSec)
+ result = append(result, encodeItem("ssnc", "ppos", positionStr)...)
+
+ // DEBUG: Log position info
+
+ return result
+}
+
+// Helper function to convert 4-char ASCII string to hex
+func asciiToHex(s string) string {
+ if len(s) != 4 {
+ panic("Input must be 4 characters")
+ }
+ return fmt.Sprintf("%02x%02x%02x%02x", s[0], s[1], s[2], s[3])
+}
+
+// Update updates the metadata with new values
+func (tm *TrackMetadata) Update(title, artist, album, trackID string, duration, position int64, volume int, playing bool, artworkURL string, artworkData []byte) {
+ tm.Title = title
+ tm.Artist = artist
+ tm.Album = album
+ tm.TrackID = trackID
+ tm.Duration = duration
+ tm.Position = position
+ tm.Volume = volume
+ tm.Playing = playing
+ tm.ArtworkURL = artworkURL
+ tm.ArtworkData = artworkData
+ tm.Timestamp = time.Now()
+}
+
+// UpdatePosition updates only the position and timestamp
+func (tm *TrackMetadata) UpdatePosition(position int64) {
+ tm.Position = position
+ tm.Timestamp = time.Now()
+}
+
+// UpdateVolume updates only the volume and timestamp
+func (tm *TrackMetadata) UpdateVolume(volume int) {
+ tm.Volume = volume
+ tm.Timestamp = time.Now()
+}
+
+// UpdatePlayingState updates only the playing state and timestamp
+func (tm *TrackMetadata) UpdatePlayingState(playing bool) {
+ tm.Playing = playing
+ tm.Timestamp = time.Now()
+}
+
+// UpdateSampleRate updates the sample rate for progress tracking
+func (tm *TrackMetadata) UpdateSampleRate(sampleRate float32) {
+ tm.SampleRate = sampleRate
+ tm.Timestamp = time.Now()
+}
+
+// encodeDACPItem encodes a string item in DACP format
+func encodeDACPItem(tag, value string) []byte {
+ valueBytes := []byte(value)
+ length := len(valueBytes)
+
+ result := make([]byte, 8+length)
+ copy(result[0:4], tag)
+ result[4] = byte(length >> 24)
+ result[5] = byte(length >> 16)
+ result[6] = byte(length >> 8)
+ result[7] = byte(length)
+ copy(result[8:], valueBytes)
+
+ return result
+}
+
+// encodeDACPInt encodes an integer item in DACP format
+func encodeDACPInt(tag string, value int) []byte {
+ result := make([]byte, 12)
+ copy(result[0:4], tag)
+ result[4] = 0
+ result[5] = 0
+ result[6] = 0
+ result[7] = 4
+ result[8] = byte(value >> 24)
+ result[9] = byte(value >> 16)
+ result[10] = byte(value >> 8)
+ result[11] = byte(value)
+
+ return result
+}
diff --git a/metadata/player_wrapper.go b/metadata/player_wrapper.go
new file mode 100644
index 0000000..1c95a9f
--- /dev/null
+++ b/metadata/player_wrapper.go
@@ -0,0 +1,121 @@
+package metadata
+
+import (
+ "sync"
+ "time"
+
+ librespot "github.com/devgianlu/go-librespot"
+ "github.com/devgianlu/go-librespot/player"
+)
+
+// PlayerMetadata wraps metadata functionality for a player
+type PlayerMetadata struct {
+ fifoManager *FIFOManager
+ metadata *TrackMetadata
+ mutex sync.RWMutex
+ enabled bool
+}
+
+// NewPlayerMetadata creates a new player metadata wrapper
+func NewPlayerMetadata(log librespot.Logger, config MetadataPipeConfig) *PlayerMetadata {
+ pm := &PlayerMetadata{
+ enabled: config.Enabled,
+ }
+
+ if config.Enabled {
+ pm.fifoManager = NewFIFOManager(log, config.Path, config.Format, config.BufferSize)
+ pm.metadata = NewTrackMetadata()
+ }
+
+ return pm
+}
+
+// Start starts the metadata system
+func (pm *PlayerMetadata) Start() error {
+ if !pm.enabled || pm.fifoManager == nil {
+ return nil
+ }
+
+ return pm.fifoManager.Start()
+}
+
+// Stop stops the metadata system
+func (pm *PlayerMetadata) Stop() {
+ if !pm.enabled || pm.fifoManager == nil {
+ return
+ }
+
+ pm.fifoManager.Stop()
+}
+
+func (pm *PlayerMetadata) UpdateTrack(info player.TrackUpdateInfo) {
+ if !pm.enabled {
+ return
+ }
+
+ pm.mutex.Lock()
+ pm.metadata.Update(info.Title, info.Artist, info.Album, info.TrackID, info.Duration.Milliseconds(), 0, pm.metadata.Volume, info.Playing, info.ArtworkURL, info.ArtworkData)
+ pm.mutex.Unlock()
+
+ pm.writeMetadata()
+}
+
+// UpdatePosition updates playback position
+func (pm *PlayerMetadata) UpdatePosition(position time.Duration) {
+ if !pm.enabled {
+ return
+ }
+
+ pm.mutex.Lock()
+ pm.metadata.UpdatePosition(position.Milliseconds())
+ pm.mutex.Unlock()
+
+ pm.writeMetadata()
+}
+
+// UpdateVolume updates volume
+func (pm *PlayerMetadata) UpdateVolume(volume int) {
+ if !pm.enabled {
+ return
+ }
+
+ pm.mutex.Lock()
+ pm.metadata.UpdateVolume(volume)
+ pm.mutex.Unlock()
+
+ pm.writeMetadata()
+}
+
+// UpdatePlayingState updates playing state
+func (pm *PlayerMetadata) UpdatePlayingState(playing bool) {
+ if !pm.enabled {
+ return
+ }
+
+ pm.mutex.Lock()
+ pm.metadata.UpdatePlayingState(playing)
+ pm.mutex.Unlock()
+
+ pm.writeMetadata()
+}
+
+// writeMetadata writes current metadata to FIFO
+func (pm *PlayerMetadata) writeMetadata() {
+ if pm.fifoManager == nil {
+ return
+ }
+
+ pm.mutex.RLock()
+ metadataCopy := *pm.metadata // Make a copy to avoid race conditions
+ pm.mutex.RUnlock()
+
+ pm.fifoManager.WriteMetadata(&metadataCopy)
+}
+
+// MetadataPipeConfig represents metadata pipe configuration
+type MetadataPipeConfig struct {
+ Enabled bool
+ Path string
+ Format string
+ BufferSize int
+}
diff --git a/player/player.go b/player/player.go
index cdefca0..153d038 100644
--- a/player/player.go
+++ b/player/player.go
@@ -90,6 +90,26 @@ type playerCmdDataSet struct {
drop bool
}
+// Update MetadataCallback interface:
+// TrackUpdateInfo contains information for updating track metadata
+type TrackUpdateInfo struct {
+ Title string
+ Artist string
+ Album string
+ TrackID string
+ Duration time.Duration
+ Playing bool
+ ArtworkURL string
+ ArtworkData []byte
+}
+
+type MetadataCallback interface {
+ UpdateTrack(info TrackUpdateInfo)
+ UpdatePosition(position time.Duration)
+ UpdateVolume(volume int)
+ UpdatePlayingState(playing bool)
+}
+
type Options struct {
Spclient *spclient.Spclient
AudioKey *audio.KeyProvider
@@ -158,6 +178,7 @@ type Options struct {
//
// This is only supported on the pipe backend.
AudioOutputPipeFormat string
+ MetadataCallback MetadataCallback
}
func NewPlayer(opts *Options) (*Player, error) {