-
Couldn't load subscription status.
- Fork 36
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
4079b8f
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 |
|---|---|---|
|
|
@@ -6,4 +6,6 @@ | |
| /state.json | ||
| /lockfile | ||
|
|
||
| /dist | ||
| /dist | ||
| /daemon | ||
| /go-librespot | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,18 +7,90 @@ import ( | |
| "encoding/hex" | ||
| "errors" | ||
| "fmt" | ||
| "io" | ||
| "math" | ||
| "net/http" | ||
| "strconv" | ||
| "time" | ||
|
|
||
| librespot "github.com/devgianlu/go-librespot" | ||
| "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" | ||
| ) | ||
|
|
||
| // Update extractMetadataFromStream method signature: | ||
|
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) extractMetadataFromStream(stream *player.Stream) (title, artist, album, trackID string, duration time.Duration, artworkURL string, artworkData []byte) { | ||
| if stream == nil || stream.Media == nil { | ||
| return "", "", "", "", 0, "", nil | ||
| } | ||
|
|
||
| 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 | ||
| } | ||
|
|
||
| // GET ALBUM ARTWORK URL AND DATA: | ||
| 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 | ||
| } | ||
|
|
||
| // GET SHOW ARTWORK URL AND DATA: | ||
| artworkURL = p.getShowArtworkURL(episode.Show) | ||
| if artworkURL != "" { | ||
| artworkData = p.downloadArtwork(artworkURL) | ||
| } | ||
| } | ||
| album = "Podcast" // Generic album name for episodes | ||
| } | ||
| } | ||
|
|
||
| return title, artist, album, trackID, duration, artworkURL, artworkData | ||
|
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 not be returning that many values |
||
| } | ||
|
|
||
| func (p *AppPlayer) prefetchNext() { | ||
| ctx := context.TODO() | ||
|
|
||
|
|
@@ -92,6 +164,13 @@ func (p *AppPlayer) handlePlayerEvent(ctx context.Context, ev *player.Event) { | |
| p.state.tracks.CurrentTrack(), | ||
| p.state.trackPosition(), | ||
| ) | ||
| /* | ||
| if p.primaryStream != nil { | ||
| title, artist, album, trackID, duration, artworkURL := p.extractMetadataFromStream(p.primaryStream) | ||
| p.app.log.Debugf("Sending metadata: %s by %s", title, artist) | ||
| p.UpdateTrack(title, artist, album, trackID, duration, true, artworkURL) // true = playing | ||
| } | ||
| */ | ||
|
Comment on lines
+167
to
+173
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. Can this be definitely removed? |
||
|
|
||
| p.app.server.Emit(&ApiEvent{ | ||
| Type: ApiEventTypePlaying, | ||
|
|
@@ -110,6 +189,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 | ||
|
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 |
||
| p.UpdatePosition(time.Duration(p.player.PositionMs()) * time.Millisecond) | ||
|
|
||
| p.app.server.Emit(&ApiEvent{ | ||
| Type: ApiEventTypePlaying, | ||
| Data: ApiEventDataPlaying{ | ||
|
|
@@ -134,6 +218,8 @@ func (p *AppPlayer) handlePlayerEvent(ctx context.Context, ev *player.Event) { | |
| p.state.trackPosition(), | ||
| ) | ||
|
|
||
| p.UpdatePlayingState(false) | ||
|
|
||
| p.app.server.Emit(&ApiEvent{ | ||
| Type: ApiEventTypePaused, | ||
| Data: ApiEventDataPaused{ | ||
|
|
@@ -145,6 +231,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{ | ||
|
|
@@ -169,6 +257,9 @@ func (p *AppPlayer) handlePlayerEvent(ctx context.Context, ev *player.Event) { | |
| }) | ||
| } | ||
| case player.EventTypeStop: | ||
|
|
||
| p.UpdatePlayingState(false) | ||
|
|
||
| p.app.server.Emit(&ApiEvent{ | ||
| Type: ApiEventTypeStopped, | ||
| Data: ApiEventDataStopped{ | ||
|
|
@@ -301,6 +392,19 @@ func (p *AppPlayer) loadCurrentTrack(ctx context.Context, paused, drop bool) err | |
|
|
||
| p.sess.Events().PostPrimaryStreamLoad(p.primaryStream, paused) | ||
|
|
||
| // In loadCurrentTrack method: | ||
|
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 |
||
| if p.primaryStream != nil { | ||
| trackPosition := p.state.trackPosition() // Get the current position | ||
| title, artist, album, trackID, duration, artworkURL, artworkData := p.extractMetadataFromStream(p.primaryStream) | ||
| p.app.log.Debugf("Sending metadata: %s by %s (artwork: %d bytes, position: %dms)", title, artist, len(artworkData), trackPosition) | ||
|
|
||
| // First update the track (without position to avoid breaking other callers) | ||
| p.UpdateTrack(title, artist, album, trackID, duration, !paused, artworkURL, artworkData) | ||
|
|
||
| // 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(), | ||
|
|
@@ -478,6 +582,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.server.Emit(&ApiEvent{ | ||
| Type: ApiEventTypeSeek, | ||
| Data: ApiEventDataSeek{ | ||
|
|
@@ -676,6 +782,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. | ||
|
|
@@ -694,3 +803,66 @@ func (p *AppPlayer) volumeUpdated(ctx context.Context) { | |
| }, | ||
| }) | ||
| } | ||
|
|
||
| func (p *AppPlayer) getAlbumArtworkURL(album *metadatapb.Album) string { | ||
|
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 { | ||
|
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: | ||
|
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 | ||
|
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} | ||
|
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 | ||
|
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 | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -15,6 +15,7 @@ import ( | |
| librespot "github.com/devgianlu/go-librespot" | ||
|
|
||
| "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" | ||
|
|
@@ -115,6 +116,13 @@ 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.AfterFunc(time.Duration(math.MaxInt64), appPlayer.prefetchNext) | ||
|
|
||
|
|
@@ -158,11 +166,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).Errorf("failed to start metadata system") | ||
| } | ||
|
|
||
| return appPlayer, nil | ||
| } | ||
|
|
||
|
|
@@ -413,6 +426,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 { | ||
|
|
@@ -466,8 +485,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
+490
to
+493
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) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -20,6 +20,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" | ||
|
|
@@ -46,7 +47,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 { | ||
|
|
@@ -625,3 +627,28 @@ func (p *AppPlayer) Run(ctx context.Context, apiRecv <-chan ApiRequest) { | |
| } | ||
| } | ||
| } | ||
|
|
||
| // Update AppPlayer's UpdateTrack method: | ||
|
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) UpdateTrack(title, artist, album, trackID string, duration time.Duration, playing bool, artworkURL string, artworkData []byte) { | ||
| if p.metadataPlayer != nil { | ||
| p.metadataPlayer.UpdateTrack(title, artist, album, trackID, duration, playing, artworkURL, artworkData) | ||
| } | ||
| } | ||
|
|
||
| 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) | ||
| } | ||
| } | ||
|
Comment on lines
+632
to
+654
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. I think it's a bit misleading to have these as "first-class" methods of |
||
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.
I guess these are because you are cross-compiling, but they are a bit misleading