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) {