-
Notifications
You must be signed in to change notification settings - Fork 37
Add metadata to fifo pipe output #203
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
32cc168
2cb7127
f12d7b4
96b4688
49d2f04
ff25da7
3ec9a15
d44c298
ea02188
3c7e5df
1275a6e
f4c58a3
9039bbf
b2be2d7
0416de7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -12,4 +12,7 @@ | |
| /.gocache | ||
| /vcpkg | ||
| /vcpkg_installed | ||
| /dist | ||
| /distgo-librespot | ||
| *.tar.gz | ||
| sedtmp | ||
| .go-librespot | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 { | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This can be a free function |
||
| 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 { | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This can be a free function |
||
| 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: | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Tautological comment |
||
| func (p *AppPlayer) downloadArtwork(url string) []byte { | ||
| if url == "" { | ||
| return nil | ||
| } | ||
|
|
||
| // Download with timeout | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Tautological comment |
||
| client := &http.Client{Timeout: 5 * time.Second} | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should avoid creating a new HTTP client for each request, perhaps this can live in a separate service or reach to an already available HTTP client? |
||
| 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 | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why limit to 1M? |
||
| 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 | ||
|
|
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
|
Comment on lines
+503
to
+506
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The default value for |
||
| }, "."), nil) | ||
|
|
||
| // load file configuration (if available) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Tautological comment