Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,6 @@
/state.json
/lockfile

/dist
/dist
/daemon
/go-librespot
Comment on lines +10 to +11
Copy link
Owner

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

172 changes: 172 additions & 0 deletions cmd/daemon/controls.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Copy link
Owner

Choose a reason for hiding this comment

The 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
Copy link
Owner

Choose a reason for hiding this comment

The 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()

Expand Down Expand Up @@ -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
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this be definitely removed?


p.app.server.Emit(&ApiEvent{
Type: ApiEventTypePlaying,
Expand All @@ -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
Copy link
Owner

Choose a reason for hiding this comment

The 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{
Expand All @@ -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{
Expand All @@ -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{
Expand All @@ -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{
Expand Down Expand Up @@ -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:
Copy link
Owner

Choose a reason for hiding this comment

The 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(),
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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.
Expand All @@ -694,3 +803,66 @@ func (p *AppPlayer) volumeUpdated(ctx context.Context) {
},
})
}

func (p *AppPlayer) getAlbumArtworkURL(album *metadatapb.Album) string {
Copy link
Owner

Choose a reason for hiding this comment

The 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 {
Copy link
Owner

Choose a reason for hiding this comment

The 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:
Copy link
Owner

Choose a reason for hiding this comment

The 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
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tautological comment

client := &http.Client{Timeout: 5 * time.Second}
Copy link
Owner

Choose a reason for hiding this comment

The 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
Copy link
Owner

Choose a reason for hiding this comment

The 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
}
27 changes: 25 additions & 2 deletions cmd/daemon/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The default value for metadata_pipe.enabled is not needed and I would leave metadata_pipe.path without a default.

}, "."), nil)

// load file configuration (if available)
Expand Down
29 changes: 28 additions & 1 deletion cmd/daemon/player.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 {
Expand Down Expand Up @@ -625,3 +627,28 @@ func (p *AppPlayer) Run(ctx context.Context, apiRecv <-chan ApiRequest) {
}
}
}

// Update AppPlayer's UpdateTrack method:
Copy link
Owner

Choose a reason for hiding this comment

The 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
Copy link
Owner

Choose a reason for hiding this comment

The 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 AppPlayer. Also, I think you are misusing the interface pattern, there is no need for AppPlayer to implement MetadataCallback since you can call the methods you are calling here directly from the code in controls.go.

Loading