diff --git a/cmd/daemon/controls.go b/cmd/daemon/controls.go index 2f9f107..dca982e 100644 --- a/cmd/daemon/controls.go +++ b/cmd/daemon/controls.go @@ -10,44 +10,44 @@ import ( "time" ) -func (s *Session) handlePlayerEvent(ev *player.Event) { +func (p *AppPlayer) handlePlayerEvent(ev *player.Event) { switch ev.Type { case player.EventTypePlaying: - s.state.player.IsPlaying = true - s.state.player.IsPaused = false - s.state.player.IsBuffering = false - s.updateState() + p.state.player.IsPlaying = true + p.state.player.IsPaused = false + p.state.player.IsBuffering = false + p.updateState() - s.app.server.Emit(&ApiEvent{ + p.app.server.Emit(&ApiEvent{ Type: ApiEventTypePlaying, Data: ApiEventDataPlaying{ - Uri: s.state.player.Track.Uri, - PlayOrigin: s.state.playOrigin(), + Uri: p.state.player.Track.Uri, + PlayOrigin: p.state.playOrigin(), }, }) case player.EventTypePaused: - s.state.player.IsPlaying = true - s.state.player.IsPaused = true - s.state.player.IsBuffering = false - s.updateState() + p.state.player.IsPlaying = true + p.state.player.IsPaused = true + p.state.player.IsBuffering = false + p.updateState() - s.app.server.Emit(&ApiEvent{ + p.app.server.Emit(&ApiEvent{ Type: ApiEventTypePaused, Data: ApiEventDataPaused{ - Uri: s.state.player.Track.Uri, - PlayOrigin: s.state.playOrigin(), + Uri: p.state.player.Track.Uri, + PlayOrigin: p.state.playOrigin(), }, }) case player.EventTypeNotPlaying: - s.app.server.Emit(&ApiEvent{ + p.app.server.Emit(&ApiEvent{ Type: ApiEventTypeNotPlaying, Data: ApiEventDataNotPlaying{ - Uri: s.state.player.Track.Uri, - PlayOrigin: s.state.playOrigin(), + Uri: p.state.player.Track.Uri, + PlayOrigin: p.state.playOrigin(), }, }) - hasNextTrack, err := s.advanceNext(false) + hasNextTrack, err := p.advanceNext(false) if err != nil { // TODO: move into stopped state log.WithError(err).Error("failed advancing to next track") @@ -55,10 +55,10 @@ func (s *Session) handlePlayerEvent(ev *player.Event) { // if no track to be played, just stop if !hasNextTrack { - s.app.server.Emit(&ApiEvent{ + p.app.server.Emit(&ApiEvent{ Type: ApiEventTypeStopped, Data: ApiEventDataStopped{ - PlayOrigin: s.state.playOrigin(), + PlayOrigin: p.state.playOrigin(), }, }) } @@ -69,103 +69,103 @@ func (s *Session) handlePlayerEvent(ev *player.Event) { } } -func (s *Session) loadContext(ctx *connectpb.Context, skipTo func(*connectpb.ContextTrack) bool, paused bool) error { - tracks, err := NewTrackListFromContext(s.sp, ctx) +func (p *AppPlayer) loadContext(ctx *connectpb.Context, skipTo func(*connectpb.ContextTrack) bool, paused bool) error { + tracks, err := NewTrackListFromContext(p.sess.Spclient(), ctx) if err != nil { return fmt.Errorf("failed creating track list: %w", err) } - s.state.player.IsPaused = paused + p.state.player.IsPaused = paused - s.state.player.ContextUri = ctx.Uri - s.state.player.ContextUrl = ctx.Url - s.state.player.ContextRestrictions = ctx.Restrictions + p.state.player.ContextUri = ctx.Uri + p.state.player.ContextUrl = ctx.Url + p.state.player.ContextRestrictions = ctx.Restrictions - if s.state.player.ContextMetadata == nil { - s.state.player.ContextMetadata = map[string]string{} + if p.state.player.ContextMetadata == nil { + p.state.player.ContextMetadata = map[string]string{} } for k, v := range ctx.Metadata { - s.state.player.ContextMetadata[k] = v + p.state.player.ContextMetadata[k] = v } - s.state.player.Timestamp = time.Now().UnixMilli() - s.state.player.PositionAsOfTimestamp = 0 + p.state.player.Timestamp = time.Now().UnixMilli() + p.state.player.PositionAsOfTimestamp = 0 // if we fail to seek, just fallback to the first track tracks.TrySeek(skipTo) - s.state.tracks = tracks - s.state.player.Track = tracks.CurrentTrack() - s.state.player.PrevTracks = tracks.PrevTracks() - s.state.player.NextTracks = tracks.NextTracks() - s.state.player.Index = tracks.Index() + p.state.tracks = tracks + p.state.player.Track = tracks.CurrentTrack() + p.state.player.PrevTracks = tracks.PrevTracks() + p.state.player.NextTracks = tracks.NextTracks() + p.state.player.Index = tracks.Index() // load current track into stream - if err := s.loadCurrentTrack(paused); err != nil { + if err := p.loadCurrentTrack(paused); err != nil { return fmt.Errorf("failed loading current track (load context): %w", err) } return nil } -func (s *Session) loadCurrentTrack(paused bool) error { - if s.stream != nil { - s.stream.Stop() - s.stream = nil +func (p *AppPlayer) loadCurrentTrack(paused bool) error { + if p.stream != nil { + p.stream.Stop() + p.stream = nil } - spotId := librespot.SpotifyIdFromUri(s.state.player.Track.Uri) + spotId := librespot.SpotifyIdFromUri(p.state.player.Track.Uri) println("spot", spotId.Type(), spotId.Uri()) if spotId.Type() != librespot.SpotifyIdTypeTrack { return fmt.Errorf("unsupported spotify type: %s", spotId.Type()) } - trackPosition := s.state.trackPosition() + trackPosition := p.state.trackPosition() log.Debugf("loading %s %s (paused: %t, position: %dms)", spotId.Type(), spotId.Uri(), paused, trackPosition) - s.state.player.IsPlaying = true - s.state.player.IsBuffering = true - s.state.player.IsPaused = paused - s.updateState() + p.state.player.IsPlaying = true + p.state.player.IsBuffering = true + p.state.player.IsPaused = paused + p.updateState() - s.app.server.Emit(&ApiEvent{ + p.app.server.Emit(&ApiEvent{ Type: ApiEventTypeWillPlay, Data: ApiEventDataWillPlay{ Uri: spotId.Uri(), - PlayOrigin: s.state.playOrigin(), + PlayOrigin: p.state.playOrigin(), }, }) - stream, err := s.player.NewStream(spotId, s.app.cfg.Bitrate, trackPosition, paused) + stream, err := p.player.NewStream(spotId, p.app.cfg.Bitrate, trackPosition, paused) if err != nil { return fmt.Errorf("failed creating stream: %w", err) } log.Infof("loaded track \"%s\" (uri: %s, paused: %t, position: %dms, duration: %dms)", *stream.Track.Name, spotId.Uri(), paused, trackPosition, *stream.Track.Duration) - s.state.player.Duration = int64(*stream.Track.Duration) - s.state.player.IsPlaying = true - s.state.player.IsBuffering = false - s.updateState() + p.state.player.Duration = int64(*stream.Track.Duration) + p.state.player.IsPlaying = true + p.state.player.IsBuffering = false + p.updateState() - s.app.server.Emit(&ApiEvent{ + p.app.server.Emit(&ApiEvent{ Type: ApiEventTypeMetadata, - Data: ApiEventDataMetadata(*NewApiResponseStatusTrack(stream.Track, s.prodInfo, trackPosition)), + Data: ApiEventDataMetadata(*NewApiResponseStatusTrack(stream.Track, p.prodInfo, trackPosition)), }) - s.stream = stream + p.stream = stream return nil } -func (s *Session) setRepeatingContext(val bool) { - if val == s.state.player.Options.RepeatingContext { +func (p *AppPlayer) setRepeatingContext(val bool) { + if val == p.state.player.Options.RepeatingContext { return } - s.state.player.Options.RepeatingContext = val - s.updateState() + p.state.player.Options.RepeatingContext = val + p.updateState() - s.app.server.Emit(&ApiEvent{ + p.app.server.Emit(&ApiEvent{ Type: ApiEventTypeRepeatContext, Data: ApiEventDataRepeatContext{ Value: val, @@ -173,15 +173,15 @@ func (s *Session) setRepeatingContext(val bool) { }) } -func (s *Session) setRepeatingTrack(val bool) { - if val == s.state.player.Options.RepeatingTrack { +func (p *AppPlayer) setRepeatingTrack(val bool) { + if val == p.state.player.Options.RepeatingTrack { return } - s.state.player.Options.RepeatingTrack = val - s.updateState() + p.state.player.Options.RepeatingTrack = val + p.updateState() - s.app.server.Emit(&ApiEvent{ + p.app.server.Emit(&ApiEvent{ Type: ApiEventTypeRepeatTrack, Data: ApiEventDataRepeatTrack{ Value: val, @@ -189,17 +189,17 @@ func (s *Session) setRepeatingTrack(val bool) { }) } -func (s *Session) setShufflingContext(val bool) { - if val == s.state.player.Options.ShufflingContext { +func (p *AppPlayer) setShufflingContext(val bool) { + if val == p.state.player.Options.ShufflingContext { return } // TODO: support shuffling context log.Warnf("shuffle context is not supported yet") - s.state.player.Options.ShufflingContext = val - s.updateState() + p.state.player.Options.ShufflingContext = val + p.updateState() - s.app.server.Emit(&ApiEvent{ + p.app.server.Emit(&ApiEvent{ Type: ApiEventTypeShuffleContext, Data: ApiEventDataShuffleContext{ Value: val, @@ -207,165 +207,165 @@ func (s *Session) setShufflingContext(val bool) { }) } -func (s *Session) play() error { - if s.stream == nil { +func (p *AppPlayer) play() error { + if p.stream == nil { return fmt.Errorf("no stream") } // seek before play to ensure we are at the correct stream position - if err := s.stream.SeekMs(s.state.trackPosition()); err != nil { + if err := p.stream.SeekMs(p.state.trackPosition()); err != nil { return fmt.Errorf("failed seeking before play: %w", err) } - s.stream.Play() + p.stream.Play() - streamPos := s.stream.PositionMs() + streamPos := p.stream.PositionMs() log.Debugf("resume track at %dms", streamPos) - s.state.player.Timestamp = time.Now().UnixMilli() - s.state.player.PositionAsOfTimestamp = streamPos - s.state.player.IsPaused = false - s.updateState() + p.state.player.Timestamp = time.Now().UnixMilli() + p.state.player.PositionAsOfTimestamp = streamPos + p.state.player.IsPaused = false + p.updateState() return nil } -func (s *Session) pause() error { - if s.stream == nil { +func (p *AppPlayer) pause() error { + if p.stream == nil { return fmt.Errorf("no stream") } - streamPos := s.stream.PositionMs() + streamPos := p.stream.PositionMs() log.Debugf("pause track at %dms", streamPos) - s.stream.Pause() + p.stream.Pause() - s.state.player.Timestamp = time.Now().UnixMilli() - s.state.player.PositionAsOfTimestamp = streamPos - s.state.player.IsPaused = true - s.updateState() + p.state.player.Timestamp = time.Now().UnixMilli() + p.state.player.PositionAsOfTimestamp = streamPos + p.state.player.IsPaused = true + p.updateState() return nil } -func (s *Session) seek(position int64) error { - if s.stream == nil { +func (p *AppPlayer) seek(position int64) error { + if p.stream == nil { return fmt.Errorf("no stream") } log.Debugf("seek track to %dms", position) - if err := s.stream.SeekMs(position); err != nil { + if err := p.stream.SeekMs(position); err != nil { return err } - s.state.player.Timestamp = time.Now().UnixMilli() - s.state.player.PositionAsOfTimestamp = position - s.updateState() + p.state.player.Timestamp = time.Now().UnixMilli() + p.state.player.PositionAsOfTimestamp = position + p.updateState() - s.app.server.Emit(&ApiEvent{ + p.app.server.Emit(&ApiEvent{ Type: ApiEventTypeSeek, Data: ApiEventDataSeek{ - Uri: s.state.player.Track.Uri, + Uri: p.state.player.Track.Uri, Position: int(position), - Duration: int(*s.stream.Track.Duration), - PlayOrigin: s.state.playOrigin(), + Duration: int(*p.stream.Track.Duration), + PlayOrigin: p.state.playOrigin(), }, }) return nil } -func (s *Session) skipPrev() error { - if s.stream.PositionMs() > 3000 { - return s.seek(0) +func (p *AppPlayer) skipPrev() error { + if p.stream.PositionMs() > 3000 { + return p.seek(0) } - if s.state.tracks != nil { + if p.state.tracks != nil { log.Debug("skip previous track") - s.state.tracks.GoPrev() + p.state.tracks.GoPrev() - s.state.player.Track = s.state.tracks.CurrentTrack() - s.state.player.PrevTracks = s.state.tracks.PrevTracks() - s.state.player.NextTracks = s.state.tracks.NextTracks() - s.state.player.Index = s.state.tracks.Index() + p.state.player.Track = p.state.tracks.CurrentTrack() + p.state.player.PrevTracks = p.state.tracks.PrevTracks() + p.state.player.NextTracks = p.state.tracks.NextTracks() + p.state.player.Index = p.state.tracks.Index() } - s.state.player.Timestamp = time.Now().UnixMilli() - s.state.player.PositionAsOfTimestamp = 0 + p.state.player.Timestamp = time.Now().UnixMilli() + p.state.player.PositionAsOfTimestamp = 0 // load current track into stream - if err := s.loadCurrentTrack(s.state.player.IsPaused); err != nil { + if err := p.loadCurrentTrack(p.state.player.IsPaused); err != nil { return fmt.Errorf("failed loading current track (skip prev): %w", err) } return nil } -func (s *Session) skipNext() error { - if s.state.tracks != nil { +func (p *AppPlayer) skipNext() error { + if p.state.tracks != nil { log.Debug("skip next track") - s.state.tracks.GoNext() + p.state.tracks.GoNext() - s.state.player.Track = s.state.tracks.CurrentTrack() - s.state.player.PrevTracks = s.state.tracks.PrevTracks() - s.state.player.NextTracks = s.state.tracks.NextTracks() - s.state.player.Index = s.state.tracks.Index() + p.state.player.Track = p.state.tracks.CurrentTrack() + p.state.player.PrevTracks = p.state.tracks.PrevTracks() + p.state.player.NextTracks = p.state.tracks.NextTracks() + p.state.player.Index = p.state.tracks.Index() } - s.state.player.Timestamp = time.Now().UnixMilli() - s.state.player.PositionAsOfTimestamp = 0 + p.state.player.Timestamp = time.Now().UnixMilli() + p.state.player.PositionAsOfTimestamp = 0 // load current track into stream - if err := s.loadCurrentTrack(s.state.player.IsPaused); err != nil { + if err := p.loadCurrentTrack(p.state.player.IsPaused); err != nil { return fmt.Errorf("failed loading current track (skip next): %w", err) } return nil } -func (s *Session) advanceNext(forceNext bool) (bool, error) { +func (p *AppPlayer) advanceNext(forceNext bool) (bool, error) { var uri string var hasNextTrack bool - if s.state.tracks != nil { - if !forceNext && s.state.player.Options.RepeatingTrack { + if p.state.tracks != nil { + if !forceNext && p.state.player.Options.RepeatingTrack { hasNextTrack = true - s.state.player.IsPaused = false + p.state.player.IsPaused = false } else { // try to get the next track - hasNextTrack = s.state.tracks.GoNext() + hasNextTrack = p.state.tracks.GoNext() // if we could not get the next track we probably ended the context - if !hasNextTrack && s.state.player.Options.RepeatingContext { - hasNextTrack = s.state.tracks.GoStart() + if !hasNextTrack && p.state.player.Options.RepeatingContext { + hasNextTrack = p.state.tracks.GoStart() } - s.state.player.IsPaused = !hasNextTrack + p.state.player.IsPaused = !hasNextTrack } - s.state.player.Track = s.state.tracks.CurrentTrack() - s.state.player.PrevTracks = s.state.tracks.PrevTracks() - s.state.player.NextTracks = s.state.tracks.NextTracks() - s.state.player.Index = s.state.tracks.Index() + p.state.player.Track = p.state.tracks.CurrentTrack() + p.state.player.PrevTracks = p.state.tracks.PrevTracks() + p.state.player.NextTracks = p.state.tracks.NextTracks() + p.state.player.Index = p.state.tracks.Index() - uri = s.state.player.Track.Uri + uri = p.state.player.Track.Uri } - s.state.player.Timestamp = time.Now().UnixMilli() - s.state.player.PositionAsOfTimestamp = 0 + p.state.player.Timestamp = time.Now().UnixMilli() + p.state.player.PositionAsOfTimestamp = 0 if !hasNextTrack { - s.state.player.IsPlaying = false - s.state.player.IsPaused = false - s.state.player.IsBuffering = false + p.state.player.IsPlaying = false + p.state.player.IsPaused = false + p.state.player.IsBuffering = false } // load current track into stream - if err := s.loadCurrentTrack(!hasNextTrack); errors.Is(err, librespot.ErrTrackRestricted) { + if err := p.loadCurrentTrack(!hasNextTrack); errors.Is(err, librespot.ErrTrackRestricted) { log.Infof("skipping restricted track: %s", uri) if forceNext { // we failed in finding another track to play, just stop return false, err } - return s.advanceNext(true) + return p.advanceNext(true) } else if err != nil { return false, fmt.Errorf("failed loading current track (advance to %s): %w", uri, err) } @@ -373,7 +373,7 @@ func (s *Session) advanceNext(forceNext bool) (bool, error) { return hasNextTrack, nil } -func (s *Session) updateVolume(newVal uint32) { +func (p *AppPlayer) updateVolume(newVal uint32) { if newVal > player.MaxStateVolume { newVal = player.MaxStateVolume } else if newVal < 0 { @@ -381,18 +381,18 @@ func (s *Session) updateVolume(newVal uint32) { } log.Debugf("update volume to %d/%d", newVal, player.MaxStateVolume) - s.player.SetVolume(newVal) - s.state.device.Volume = newVal + p.player.SetVolume(newVal) + p.state.device.Volume = newVal - if err := s.putConnectState(connectpb.PutStateReason_VOLUME_CHANGED); err != nil { + if err := p.putConnectState(connectpb.PutStateReason_VOLUME_CHANGED); err != nil { log.WithError(err).Error("failed put state after volume change") } - s.app.server.Emit(&ApiEvent{ + p.app.server.Emit(&ApiEvent{ Type: ApiEventTypeVolume, Data: ApiEventDataVolume{ - Value: newVal * s.app.cfg.VolumeSteps / player.MaxStateVolume, - Max: s.app.cfg.VolumeSteps, + Value: newVal * p.app.cfg.VolumeSteps / player.MaxStateVolume, + Max: p.app.cfg.VolumeSteps, }, }) } diff --git a/cmd/daemon/creds.go b/cmd/daemon/creds.go deleted file mode 100644 index 803dfa7..0000000 --- a/cmd/daemon/creds.go +++ /dev/null @@ -1,24 +0,0 @@ -package main - -type SessionCredentials interface { -} - -type SessionUserPassCredentials struct { - Username string - Password string -} - -type SessionSpotifyTokenCredentials struct { - Username string - Token string -} - -type SessionStoredCredentials struct { - Username string - Data []byte -} - -type SessionBlobCredentials struct { - Username string - Blob []byte -} diff --git a/cmd/daemon/main.go b/cmd/daemon/main.go index e377ae4..2395712 100644 --- a/cmd/daemon/main.go +++ b/cmd/daemon/main.go @@ -8,12 +8,13 @@ import ( "fmt" log "github.com/sirupsen/logrus" "go-librespot/apresolve" + "go-librespot/player" devicespb "go-librespot/proto/spotify/connectstate/devices" + "go-librespot/session" "go-librespot/zeroconf" "gopkg.in/yaml.v3" "os" "strings" - "sync" ) type App struct { @@ -25,9 +26,6 @@ type App struct { deviceType devicespb.DeviceType clientToken string - sess *Session - sessLock sync.Mutex - server *ApiServer } @@ -42,7 +40,6 @@ func parseDeviceType(val string) (devicespb.DeviceType, error) { func NewApp(cfg *Config) (app *App, err error) { app = &App{cfg: cfg} - app.resolver = apresolve.NewApResolver() app.deviceType, err = parseDeviceType(cfg.DeviceType) if err != nil { @@ -58,36 +55,37 @@ func NewApp(cfg *Config) (app *App, err error) { app.deviceId = cfg.DeviceId } - if len(cfg.ClientToken) == 0 { - app.clientToken, err = retrieveClientToken(app.deviceId) - if err != nil { - return nil, fmt.Errorf("failed obtaining client token: %w", err) - } - } else { + if len(cfg.ClientToken) > 0 { app.clientToken = cfg.ClientToken } return app, nil } -func (app *App) newSession(creds SessionCredentials) (*Session, error) { - // connect new session - sess := &Session{app: app, countryCode: new(string)} - if err := sess.Connect(creds); err != nil { +func (app *App) newAppPlayer(creds any) (_ *AppPlayer, err error) { + appPlayer := &AppPlayer{ + app: app, + stop: make(chan struct{}, 1), + countryCode: new(string), + } + + if appPlayer.sess, err = session.NewSessionFromOptions(&session.Options{ + DeviceType: app.deviceType, + DeviceId: app.deviceId, + ClientToken: app.clientToken, + Resolver: app.resolver, + Credentials: creds, + }); err != nil { return nil, err } - app.sessLock.Lock() - defer app.sessLock.Unlock() + appPlayer.initState() - // disconnect previous session - if app.sess != nil { - app.sess.Close() + if appPlayer.player, err = player.NewPlayer(appPlayer.sess.Spclient(), appPlayer.sess.AudioKey(), appPlayer.countryCode, app.cfg.AudioDevice, app.cfg.VolumeSteps); err != nil { + return nil, fmt.Errorf("failed initializing player: %w", err) } - // update current session - app.sess = sess - return sess, nil + return appPlayer, nil } func (app *App) Zeroconf() error { @@ -103,35 +101,35 @@ func (app *App) Zeroconf() error { } // TODO: unset this when logging out - var currentSession *Session - var sessCh chan ApiRequest + var currentPlayer *AppPlayer + var apiCh chan ApiRequest // forward API requests to proper channel only if a session is present go func() { for { select { case req := <-app.server.Receive(): - if currentSession == nil { + if currentPlayer == nil { req.Reply(nil, ErrNoSession) break } // if we are here a channel must exist - sessCh <- req + apiCh <- req } } }() return z.Serve(func(req zeroconf.NewUserRequest) bool { - if currentSession != nil { - currentSession.Close() - currentSession = nil + if currentPlayer != nil { + currentPlayer.Close() + currentPlayer = nil // close the channel after setting the current session to nil - close(sessCh) + close(apiCh) } - sess, err := app.newSession(SessionBlobCredentials{ + appPlayer, err := app.newAppPlayer(session.BlobCredentials{ Username: req.Username, Blob: req.AuthBlob, }) @@ -141,10 +139,10 @@ func (app *App) Zeroconf() error { } // first create the channel and then assign the current session - sessCh = make(chan ApiRequest) - currentSession = sess + apiCh = make(chan ApiRequest) + currentPlayer = appPlayer - go sess.Run(sessCh) + go appPlayer.Run(apiCh) return true }) } @@ -155,19 +153,19 @@ type storedCredentialsFile struct { } func (app *App) SpotifyToken(username, token string) error { - return app.withReusableCredentials(SessionSpotifyTokenCredentials{username, token}) + return app.withReusableCredentials(session.SpotifyTokenCredentials{Username: username, Token: token}) } func (app *App) UserPass(username, password string) error { - return app.withReusableCredentials(SessionUserPassCredentials{username, password}) + return app.withReusableCredentials(session.UserPassCredentials{Username: username, Password: password}) } -func (app *App) withReusableCredentials(creds SessionCredentials) (err error) { +func (app *App) withReusableCredentials(creds any) (err error) { var username string switch creds := creds.(type) { - case SessionSpotifyTokenCredentials: + case session.SpotifyTokenCredentials: username = creds.Username - case SessionUserPassCredentials: + case session.UserPassCredentials: username = creds.Username default: return fmt.Errorf("unsupported credentials for reuse") @@ -190,31 +188,31 @@ func (app *App) withReusableCredentials(creds SessionCredentials) (err error) { log.Debugf("stored credentials not found") } - var sess *Session + var appPlayer *AppPlayer if len(storedCredentials) > 0 { - sess, err = app.newSession(SessionStoredCredentials{username, storedCredentials}) + appPlayer, err = app.newAppPlayer(session.StoredCredentials{Username: username, Data: storedCredentials}) if err != nil { return err } } else { - sess, err = app.newSession(creds) + appPlayer, err = app.newAppPlayer(creds) if err != nil { return err } if content, err := json.Marshal(&storedCredentialsFile{ - Username: sess.ap.Username(), - Data: sess.ap.StoredCredentials(), + Username: appPlayer.sess.Username(), + Data: appPlayer.sess.StoredCredentials(), }); err != nil { return fmt.Errorf("failed marshalling stored credentials: %w", err) } else if err := os.WriteFile(app.cfg.CredentialsPath, content, 0600); err != nil { return fmt.Errorf("failed writing stored credentials file: %w", err) } - log.Debugf("stored credentials for %s", sess.ap.Username()) + log.Debugf("stored credentials for %s", appPlayer.sess.Username()) } - sess.Run(app.server.Receive()) + appPlayer.Run(app.server.Receive()) return nil } diff --git a/cmd/daemon/player.go b/cmd/daemon/player.go new file mode 100644 index 0000000..9ee4bce --- /dev/null +++ b/cmd/daemon/player.go @@ -0,0 +1,389 @@ +package main + +import ( + "encoding/xml" + "fmt" + log "github.com/sirupsen/logrus" + librespot "go-librespot" + "go-librespot/ap" + "go-librespot/dealer" + "go-librespot/player" + connectpb "go-librespot/proto/spotify/connectstate/model" + "go-librespot/session" + "google.golang.org/protobuf/proto" + "strings" +) + +type AppPlayer struct { + app *App + sess *session.Session + + stop chan struct{} + + player *player.Player + + spotConnId string + + // TODO: can this be factored better? + prodInfo *ProductInfo + countryCode *string + + state *State + stream *player.Stream +} + +func (p *AppPlayer) handleAccesspointPacket(pktType ap.PacketType, payload []byte) error { + switch pktType { + case ap.PacketTypeProductInfo: + var prod ProductInfo + if err := xml.Unmarshal(payload, &prod); err != nil { + return fmt.Errorf("failed umarshalling ProductInfo: %w", err) + } + + if len(prod.Products) != 1 { + return fmt.Errorf("invalid ProductInfo") + } + + p.prodInfo = &prod + return nil + case ap.PacketTypeCountryCode: + *p.countryCode = string(payload) + return nil + default: + return nil + } +} + +func (p *AppPlayer) handleDealerMessage(msg dealer.Message) error { + if strings.HasPrefix(msg.Uri, "hm://pusher/v1/connections/") { + p.spotConnId = msg.Headers["Spotify-Connection-Id"] + log.Debugf("received connection id: %s", p.spotConnId) + + // put the initial state + if err := p.putConnectState(connectpb.PutStateReason_NEW_DEVICE); err != nil { + return fmt.Errorf("failed initial state put: %w", err) + } + } else if strings.HasPrefix(msg.Uri, "hm://connect-state/v1/connect/volume") { + var setVolCmd connectpb.SetVolumeCommand + if err := proto.Unmarshal(msg.Payload, &setVolCmd); err != nil { + return fmt.Errorf("failed unmarshalling SetVolumeCommand: %w", err) + } + + p.updateVolume(uint32(setVolCmd.Volume)) + } else if strings.HasPrefix(msg.Uri, "hm://connect-state/v1/connect/logout") { + // TODO: we should do this only when using zeroconf (?) + log.Infof("logging out from %s", p.sess.Username()) + p.Close() + } else if strings.HasPrefix(msg.Uri, "hm://connect-state/v1/cluster") { + var clusterUpdate connectpb.ClusterUpdate + if err := proto.Unmarshal(msg.Payload, &clusterUpdate); err != nil { + return fmt.Errorf("failed unmarshalling ClusterUpdate: %w", err) + } + + stopBeingActive := p.state.active && clusterUpdate.Cluster.ActiveDeviceId != p.app.deviceId + + // We are still the active device, do not quit + if !stopBeingActive { + return nil + } + + if p.stream != nil { + p.stream.Stop() + p.stream = nil + } + + p.state.reset() + if err := p.putConnectState(connectpb.PutStateReason_BECAME_INACTIVE); err != nil { + return fmt.Errorf("failed inactive state put: %w", err) + } + + // TODO: logout if using zeroconf (?) + + p.app.server.Emit(&ApiEvent{ + Type: ApiEventTypeInactive, + }) + } + + return nil +} + +func (p *AppPlayer) handlePlayerCommand(req dealer.RequestPayload) error { + p.state.lastCommand = &req + + log.Debugf("handling %s player command from %s", req.Command.Endpoint, req.SentByDeviceId) + + switch req.Command.Endpoint { + case "transfer": + var transferState connectpb.TransferState + if err := proto.Unmarshal(req.Command.Data, &transferState); err != nil { + return fmt.Errorf("failed unmarshalling TransferState: %w", err) + } + + tracks, err := NewTrackListFromContext(p.sess.Spclient(), transferState.CurrentSession.Context) + if err != nil { + return fmt.Errorf("failed creating track list: %w", err) + } + + p.state.setActive(true) + p.state.player.IsPlaying = false + p.state.player.IsBuffering = false + p.state.player.IsPaused = false + + // options + p.state.player.Options = transferState.Options + + // playback + p.state.player.Timestamp = transferState.Playback.Timestamp + p.state.player.PositionAsOfTimestamp = int64(transferState.Playback.PositionAsOfTimestamp) + p.state.player.PlaybackSpeed = transferState.Playback.PlaybackSpeed + p.state.player.IsPaused = transferState.Playback.IsPaused + + // current session + p.state.player.PlayOrigin = transferState.CurrentSession.PlayOrigin + p.state.player.ContextUri = transferState.CurrentSession.Context.Uri + p.state.player.ContextUrl = transferState.CurrentSession.Context.Url + p.state.player.ContextRestrictions = transferState.CurrentSession.Context.Restrictions + p.state.player.Suppressions = transferState.CurrentSession.Suppressions + + p.state.player.ContextMetadata = map[string]string{} + for k, v := range transferState.CurrentSession.Context.Metadata { + p.state.player.ContextMetadata[k] = v + } + for k, v := range tracks.Metadata() { + p.state.player.ContextMetadata[k] = v + } + + // queue + // TODO: transfer queue + + currentTrack := librespot.ContextTrackToProvidedTrack(transferState.Playback.CurrentTrack) + if err := tracks.Seek(func(track *connectpb.ContextTrack) bool { + if len(track.Uid) > 0 && track.Uid == currentTrack.Uid { + return true + } else if len(track.Uri) > 0 && track.Uri == currentTrack.Uri { + return true + } else if len(track.Gid) > 0 && librespot.SpotifyIdFromGid(librespot.SpotifyIdTypeTrack, track.Gid).Uri() == currentTrack.Uri /* FIXME: this might not always be a track */ { + return true + } else { + return false + } + }); err != nil { + return fmt.Errorf("failed seeking to track: %w", err) + } + + p.state.tracks = tracks + p.state.player.Track = tracks.CurrentTrack() + p.state.player.PrevTracks = tracks.PrevTracks() + p.state.player.NextTracks = tracks.NextTracks() + p.state.player.Index = tracks.Index() + + // load current track into stream + if err := p.loadCurrentTrack(transferState.Playback.IsPaused); err != nil { + return fmt.Errorf("failed loading current track (transfer): %w", err) + } + + p.app.server.Emit(&ApiEvent{ + Type: ApiEventTypeActive, + }) + + return nil + case "play": + p.state.player.PlayOrigin = req.Command.PlayOrigin + p.state.player.Suppressions = req.Command.Options.Suppressions + + return p.loadContext( + req.Command.Context, + func(track *connectpb.ContextTrack) bool { + if len(req.Command.Options.SkipTo.TrackUid) > 0 && req.Command.Options.SkipTo.TrackUid == track.Uid { + return true + } else if len(req.Command.Options.SkipTo.TrackUri) > 0 && req.Command.Options.SkipTo.TrackUri == track.Uri { + return true + } else { + return false + } + }, + req.Command.Options.InitiallyPaused, + ) + case "pause": + return p.pause() + case "resume": + return p.play() + case "seek_to": + if req.Command.Relative != "beginning" { + log.Warnf("unsupported seek_to relative position: %s", req.Command.Relative) + return nil + } + + if err := p.seek(req.Command.Position); err != nil { + return fmt.Errorf("failed seeking stream: %w", err) + } + + return nil + case "skip_prev": + return p.skipPrev() + case "skip_next": + return p.skipNext() + case "update_context": + if req.Command.Context.Uri != p.state.player.ContextUri { + log.Warnf("ignoring context update for wrong uri: %s", req.Command.Context.Uri) + return nil + } + + p.state.player.ContextRestrictions = req.Command.Context.Restrictions + if p.state.player.ContextMetadata == nil { + p.state.player.ContextMetadata = map[string]string{} + } + for k, v := range req.Command.Context.Metadata { + p.state.player.ContextMetadata[k] = v + } + + p.updateState() + return nil + case "set_repeating_context": + p.setRepeatingContext(req.Command.Value.(bool)) + return nil + case "set_repeating_track": + p.setRepeatingTrack(req.Command.Value.(bool)) + return nil + case "set_shuffling_context": + p.setShufflingContext(req.Command.Value.(bool)) + return nil + default: + return fmt.Errorf("unsupported player command: %s", req.Command.Endpoint) + } +} + +func (p *AppPlayer) handleDealerRequest(req dealer.Request) error { + switch req.MessageIdent { + case "hm://connect-state/v1/player/command": + return p.handlePlayerCommand(req.Payload) + default: + log.Warnf("unknown dealer request: %s", req.MessageIdent) + return nil + } +} + +func (p *AppPlayer) handleApiRequest(req ApiRequest) (any, error) { + switch req.Type { + case ApiRequestTypeStatus: + resp := &ApiResponseStatus{ + Username: p.sess.Username(), + DeviceId: p.app.deviceId, + DeviceType: p.app.deviceType.String(), + DeviceName: p.app.cfg.DeviceName, + VolumeSteps: p.app.cfg.VolumeSteps, + Volume: p.state.device.Volume, + RepeatContext: p.state.player.Options.RepeatingContext, + RepeatTrack: p.state.player.Options.RepeatingTrack, + ShuffleContext: p.state.player.Options.ShufflingContext, + Stopped: !p.state.player.IsPlaying, + Paused: p.state.player.IsPaused, + Buffering: p.state.player.IsBuffering, + PlayOrigin: p.state.player.PlayOrigin.FeatureIdentifier, + } + + if p.stream != nil && p.prodInfo != nil { + resp.Track = NewApiResponseStatusTrack(p.stream.Track, p.prodInfo, p.state.trackPosition()) + } + + return resp, nil + case ApiRequestTypeResume: + _ = p.play() + return nil, nil + case ApiRequestTypePause: + _ = p.pause() + return nil, nil + case ApiRequestTypeSeek: + _ = p.seek(req.Data.(int64)) + return nil, nil + case ApiRequestTypePrev: + _ = p.skipPrev() + return nil, nil + case ApiRequestTypeNext: + _ = p.skipNext() + return nil, nil + case ApiRequestTypePlay: + data := req.Data.(ApiRequestDataPlay) + ctx, err := p.sess.Spclient().ContextResolve(data.Uri) + if err != nil { + return nil, fmt.Errorf("failed resolving context: %w", err) + } + + p.state.setActive(true) + p.state.player.PlaybackSpeed = 1 + p.state.player.Suppressions = &connectpb.Suppressions{} + p.state.player.PlayOrigin = &connectpb.PlayOrigin{ + FeatureIdentifier: "go-librespot", + FeatureVersion: librespot.VersionNumberString(), + } + + if err := p.loadContext(ctx, func(track *connectpb.ContextTrack) bool { + return len(data.SkipToUri) != 0 && data.SkipToUri == track.Uri + }, data.Paused); err != nil { + return nil, fmt.Errorf("failed loading context: %w", err) + } + + return nil, nil + case ApiRequestTypeGetVolume: + return &ApiResponseVolume{ + Max: p.app.cfg.VolumeSteps, + Value: p.state.device.Volume * p.app.cfg.VolumeSteps / player.MaxStateVolume, + }, nil + case ApiRequestTypeSetVolume: + vol := req.Data.(uint32) + p.updateVolume(vol * player.MaxStateVolume / p.app.cfg.VolumeSteps) + return nil, nil + case ApiRequestTypeSetRepeatingContext: + p.setRepeatingContext(req.Data.(bool)) + return nil, nil + case ApiRequestTypeSetRepeatingTrack: + p.setRepeatingTrack(req.Data.(bool)) + return nil, nil + case ApiRequestTypeSetShufflingContext: + p.setShufflingContext(req.Data.(bool)) + return nil, nil + default: + return nil, fmt.Errorf("unknown request type: %s", req.Type) + } +} + +func (p *AppPlayer) Close() { + p.stop <- struct{}{} + p.player.Close() + p.sess.Close() +} + +func (p *AppPlayer) Run(apiRecv <-chan ApiRequest) { + apRecv := p.sess.Accesspoint().Receive(ap.PacketTypeProductInfo, ap.PacketTypeCountryCode) + msgRecv := p.sess.Dealer().ReceiveMessage("hm://pusher/v1/connections/", "hm://connect-state/v1/") + reqRecv := p.sess.Dealer().ReceiveRequest("hm://connect-state/v1/player/command") + playerRecv := p.player.Receive() + + for { + select { + case <-p.stop: + return + case pkt := <-apRecv: + if err := p.handleAccesspointPacket(pkt.Type, pkt.Payload); err != nil { + log.WithError(err).Warn("failed handling accesspoint packet") + } + case msg := <-msgRecv: + if err := p.handleDealerMessage(msg); err != nil { + log.WithError(err).Warn("failed handling dealer message") + } + case req := <-reqRecv: + if err := p.handleDealerRequest(req); err != nil { + log.WithError(err).Warn("failed handling dealer request") + req.Reply(false) + } else { + log.Debugf("sending successful reply for delaer request") + req.Reply(true) + } + case req := <-apiRecv: + data, err := p.handleApiRequest(req) + req.Reply(data, err) + case ev := <-playerRecv: + p.handlePlayerEvent(&ev) + } + } +} diff --git a/cmd/daemon/session.go b/cmd/daemon/session.go deleted file mode 100644 index 4212760..0000000 --- a/cmd/daemon/session.go +++ /dev/null @@ -1,484 +0,0 @@ -package main - -import ( - "encoding/xml" - "fmt" - log "github.com/sirupsen/logrus" - librespot "go-librespot" - "go-librespot/ap" - "go-librespot/audio" - "go-librespot/dealer" - "go-librespot/login5" - "go-librespot/player" - connectpb "go-librespot/proto/spotify/connectstate/model" - credentialspb "go-librespot/proto/spotify/login5/v3/credentials" - "go-librespot/spclient" - "google.golang.org/protobuf/proto" - "strings" -) - -type Session struct { - app *App - - stop chan struct{} - - ap *ap.Accesspoint - login5 *login5.Login5 - sp *spclient.Spclient - dealer *dealer.Dealer - - audioKey *audio.KeyProvider - - player *player.Player - - spotConnId string - - // TODO: can this be factored better? - prodInfo *ProductInfo - countryCode *string - - state *State - stream *player.Stream -} - -func (s *Session) handleAccesspointPacket(pktType ap.PacketType, payload []byte) error { - switch pktType { - case ap.PacketTypeProductInfo: - var prod ProductInfo - if err := xml.Unmarshal(payload, &prod); err != nil { - return fmt.Errorf("failed umarshalling ProductInfo: %w", err) - } - - if len(prod.Products) != 1 { - return fmt.Errorf("invalid ProductInfo") - } - - s.prodInfo = &prod - return nil - case ap.PacketTypeCountryCode: - *s.countryCode = string(payload) - return nil - default: - return nil - } -} - -func (s *Session) handleDealerMessage(msg dealer.Message) error { - if strings.HasPrefix(msg.Uri, "hm://pusher/v1/connections/") { - s.spotConnId = msg.Headers["Spotify-Connection-Id"] - log.Debugf("received connection id: %s", s.spotConnId) - - // put the initial state - if err := s.putConnectState(connectpb.PutStateReason_NEW_DEVICE); err != nil { - return fmt.Errorf("failed initial state put: %w", err) - } - } else if strings.HasPrefix(msg.Uri, "hm://connect-state/v1/connect/volume") { - var setVolCmd connectpb.SetVolumeCommand - if err := proto.Unmarshal(msg.Payload, &setVolCmd); err != nil { - return fmt.Errorf("failed unmarshalling SetVolumeCommand: %w", err) - } - - s.updateVolume(uint32(setVolCmd.Volume)) - } else if strings.HasPrefix(msg.Uri, "hm://connect-state/v1/connect/logout") { - // TODO: we should do this only when using zeroconf (?) - log.Infof("logging out from %s", s.ap.Username()) - s.Close() - } else if strings.HasPrefix(msg.Uri, "hm://connect-state/v1/cluster") { - var clusterUpdate connectpb.ClusterUpdate - if err := proto.Unmarshal(msg.Payload, &clusterUpdate); err != nil { - return fmt.Errorf("failed unmarshalling ClusterUpdate: %w", err) - } - - stopBeingActive := s.state.active && clusterUpdate.Cluster.ActiveDeviceId != s.app.deviceId - - // We are still the active device, do not quit - if !stopBeingActive { - return nil - } - - if s.stream != nil { - s.stream.Stop() - s.stream = nil - } - - s.state.reset() - if err := s.putConnectState(connectpb.PutStateReason_BECAME_INACTIVE); err != nil { - return fmt.Errorf("failed inactive state put: %w", err) - } - - // TODO: logout if using zeroconf (?) - - s.app.server.Emit(&ApiEvent{ - Type: ApiEventTypeInactive, - }) - } - - return nil -} - -func (s *Session) handlePlayerCommand(req dealer.RequestPayload) error { - s.state.lastCommand = &req - - log.Debugf("handling %s player command from %s", req.Command.Endpoint, req.SentByDeviceId) - - switch req.Command.Endpoint { - case "transfer": - var transferState connectpb.TransferState - if err := proto.Unmarshal(req.Command.Data, &transferState); err != nil { - return fmt.Errorf("failed unmarshalling TransferState: %w", err) - } - - tracks, err := NewTrackListFromContext(s.sp, transferState.CurrentSession.Context) - if err != nil { - return fmt.Errorf("failed creating track list: %w", err) - } - - s.state.setActive(true) - s.state.player.IsPlaying = false - s.state.player.IsBuffering = false - s.state.player.IsPaused = false - - // options - s.state.player.Options = transferState.Options - - // playback - s.state.player.Timestamp = transferState.Playback.Timestamp - s.state.player.PositionAsOfTimestamp = int64(transferState.Playback.PositionAsOfTimestamp) - s.state.player.PlaybackSpeed = transferState.Playback.PlaybackSpeed - s.state.player.IsPaused = transferState.Playback.IsPaused - - // current session - s.state.player.PlayOrigin = transferState.CurrentSession.PlayOrigin - s.state.player.ContextUri = transferState.CurrentSession.Context.Uri - s.state.player.ContextUrl = transferState.CurrentSession.Context.Url - s.state.player.ContextRestrictions = transferState.CurrentSession.Context.Restrictions - s.state.player.Suppressions = transferState.CurrentSession.Suppressions - - s.state.player.ContextMetadata = map[string]string{} - for k, v := range transferState.CurrentSession.Context.Metadata { - s.state.player.ContextMetadata[k] = v - } - for k, v := range tracks.Metadata() { - s.state.player.ContextMetadata[k] = v - } - - // queue - // TODO: transfer queue - - currentTrack := librespot.ContextTrackToProvidedTrack(transferState.Playback.CurrentTrack) - if err := tracks.Seek(func(track *connectpb.ContextTrack) bool { - if len(track.Uid) > 0 && track.Uid == currentTrack.Uid { - return true - } else if len(track.Uri) > 0 && track.Uri == currentTrack.Uri { - return true - } else if len(track.Gid) > 0 && librespot.SpotifyIdFromGid(librespot.SpotifyIdTypeTrack, track.Gid).Uri() == currentTrack.Uri /* FIXME: this might not always be a track */ { - return true - } else { - return false - } - }); err != nil { - return fmt.Errorf("failed seeking to track: %w", err) - } - - s.state.tracks = tracks - s.state.player.Track = tracks.CurrentTrack() - s.state.player.PrevTracks = tracks.PrevTracks() - s.state.player.NextTracks = tracks.NextTracks() - s.state.player.Index = tracks.Index() - - // load current track into stream - if err := s.loadCurrentTrack(transferState.Playback.IsPaused); err != nil { - return fmt.Errorf("failed loading current track (transfer): %w", err) - } - - s.app.server.Emit(&ApiEvent{ - Type: ApiEventTypeActive, - }) - - return nil - case "play": - s.state.player.PlayOrigin = req.Command.PlayOrigin - s.state.player.Suppressions = req.Command.Options.Suppressions - - return s.loadContext( - req.Command.Context, - func(track *connectpb.ContextTrack) bool { - if len(req.Command.Options.SkipTo.TrackUid) > 0 && req.Command.Options.SkipTo.TrackUid == track.Uid { - return true - } else if len(req.Command.Options.SkipTo.TrackUri) > 0 && req.Command.Options.SkipTo.TrackUri == track.Uri { - return true - } else { - return false - } - }, - req.Command.Options.InitiallyPaused, - ) - case "pause": - return s.pause() - case "resume": - return s.play() - case "seek_to": - if req.Command.Relative != "beginning" { - log.Warnf("unsupported seek_to relative position: %s", req.Command.Relative) - return nil - } - - if err := s.seek(req.Command.Position); err != nil { - return fmt.Errorf("failed seeking stream: %w", err) - } - - return nil - case "skip_prev": - return s.skipPrev() - case "skip_next": - return s.skipNext() - case "update_context": - if req.Command.Context.Uri != s.state.player.ContextUri { - log.Warnf("ignoring context update for wrong uri: %s", req.Command.Context.Uri) - return nil - } - - s.state.player.ContextRestrictions = req.Command.Context.Restrictions - if s.state.player.ContextMetadata == nil { - s.state.player.ContextMetadata = map[string]string{} - } - for k, v := range req.Command.Context.Metadata { - s.state.player.ContextMetadata[k] = v - } - - s.updateState() - return nil - case "set_repeating_context": - s.setRepeatingContext(req.Command.Value.(bool)) - return nil - case "set_repeating_track": - s.setRepeatingTrack(req.Command.Value.(bool)) - return nil - case "set_shuffling_context": - s.setShufflingContext(req.Command.Value.(bool)) - return nil - default: - return fmt.Errorf("unsupported player command: %s", req.Command.Endpoint) - } -} - -func (s *Session) handleDealerRequest(req dealer.Request) error { - switch req.MessageIdent { - case "hm://connect-state/v1/player/command": - return s.handlePlayerCommand(req.Payload) - default: - log.Warnf("unknown dealer request: %s", req.MessageIdent) - return nil - } -} - -func (s *Session) handleApiRequest(req ApiRequest) (any, error) { - switch req.Type { - case ApiRequestTypeStatus: - resp := &ApiResponseStatus{ - Username: s.ap.Username(), - DeviceId: s.app.deviceId, - DeviceType: s.app.deviceType.String(), - DeviceName: s.app.cfg.DeviceName, - VolumeSteps: s.app.cfg.VolumeSteps, - Volume: s.state.device.Volume, - RepeatContext: s.state.player.Options.RepeatingContext, - RepeatTrack: s.state.player.Options.RepeatingTrack, - ShuffleContext: s.state.player.Options.ShufflingContext, - Stopped: !s.state.player.IsPlaying, - Paused: s.state.player.IsPaused, - Buffering: s.state.player.IsBuffering, - PlayOrigin: s.state.player.PlayOrigin.FeatureIdentifier, - } - - if s.stream != nil && s.prodInfo != nil { - resp.Track = NewApiResponseStatusTrack(s.stream.Track, s.prodInfo, s.state.trackPosition()) - } - - return resp, nil - case ApiRequestTypeResume: - _ = s.play() - return nil, nil - case ApiRequestTypePause: - _ = s.pause() - return nil, nil - case ApiRequestTypeSeek: - _ = s.seek(req.Data.(int64)) - return nil, nil - case ApiRequestTypePrev: - _ = s.skipPrev() - return nil, nil - case ApiRequestTypeNext: - _ = s.skipNext() - return nil, nil - case ApiRequestTypePlay: - data := req.Data.(ApiRequestDataPlay) - ctx, err := s.sp.ContextResolve(data.Uri) - if err != nil { - return nil, fmt.Errorf("failed resolving context: %w", err) - } - - s.state.setActive(true) - s.state.player.PlaybackSpeed = 1 - s.state.player.Suppressions = &connectpb.Suppressions{} - s.state.player.PlayOrigin = &connectpb.PlayOrigin{ - FeatureIdentifier: "go-librespot", - FeatureVersion: librespot.VersionNumberString(), - } - - if err := s.loadContext(ctx, func(track *connectpb.ContextTrack) bool { - return len(data.SkipToUri) != 0 && data.SkipToUri == track.Uri - }, data.Paused); err != nil { - return nil, fmt.Errorf("failed loading context: %w", err) - } - - return nil, nil - case ApiRequestTypeGetVolume: - return &ApiResponseVolume{ - Max: s.app.cfg.VolumeSteps, - Value: s.state.device.Volume * s.app.cfg.VolumeSteps / player.MaxStateVolume, - }, nil - case ApiRequestTypeSetVolume: - vol := req.Data.(uint32) - s.updateVolume(vol * player.MaxStateVolume / s.app.cfg.VolumeSteps) - return nil, nil - case ApiRequestTypeSetRepeatingContext: - s.setRepeatingContext(req.Data.(bool)) - return nil, nil - case ApiRequestTypeSetRepeatingTrack: - s.setRepeatingTrack(req.Data.(bool)) - return nil, nil - case ApiRequestTypeSetShufflingContext: - s.setShufflingContext(req.Data.(bool)) - return nil, nil - default: - return nil, fmt.Errorf("unknown request type: %s", req.Type) - } -} - -func (s *Session) Connect(creds SessionCredentials) (err error) { - s.stop = make(chan struct{}, 1) - - // init login5 - s.login5 = login5.NewLogin5(s.app.deviceId, s.app.clientToken) - - // connect and authenticate to the accesspoint - apAddr, err := s.app.resolver.GetAccesspoint() - if err != nil { - return fmt.Errorf("failed getting accesspoint from resolver: %w", err) - } - - s.ap, err = ap.NewAccesspoint(apAddr, s.app.deviceId) - if err != nil { - return fmt.Errorf("failed initializing accesspoint: %w", err) - } - - // choose proper credentials - switch creds := creds.(type) { - case SessionStoredCredentials: - if err = s.ap.ConnectStored(creds.Username, creds.Data); err != nil { - return fmt.Errorf("failed authenticating accesspoint with stored credentials: %w", err) - } - case SessionUserPassCredentials: - if err = s.ap.ConnectUserPass(creds.Username, creds.Password); err != nil { - return fmt.Errorf("failed authenticating accesspoint with username and password: %w", err) - } - case SessionSpotifyTokenCredentials: - if err = s.ap.ConnectSpotifyToken(creds.Username, creds.Token); err != nil { - return fmt.Errorf("failed authenticating accesspoint with username and spotify token: %w", err) - } - case SessionBlobCredentials: - if err = s.ap.ConnectBlob(creds.Username, creds.Blob); err != nil { - return fmt.Errorf("failed authenticating accesspoint with blob: %w", err) - } - default: - panic("unknown credentials") - } - - // authenticate with login5 and get token - if err = s.login5.Login(&credentialspb.StoredCredential{ - Username: s.ap.Username(), - Data: s.ap.StoredCredentials(), - }); err != nil { - return fmt.Errorf("failed authenticating with login5: %w", err) - } - - // initialize spclient - spAddr, err := s.app.resolver.GetSpclient() - if err != nil { - return fmt.Errorf("failed getting spclient from resolver: %w", err) - } - - s.sp, err = spclient.NewSpclient(spAddr, s.login5.AccessToken(), s.app.deviceId, s.app.clientToken) - if err != nil { - return fmt.Errorf("failed initializing spclient: %w", err) - } - - // initialize dealer - dealerAddr, err := s.app.resolver.GetDealer() - if err != nil { - return fmt.Errorf("failed getting dealer from resolver: %w", err) - } - - s.dealer, err = dealer.NewDealer(dealerAddr, s.login5.AccessToken()) - if err != nil { - return fmt.Errorf("failed connecting to dealer: %w", err) - } - - // init internal state - s.initState() - - // init audio key provider - s.audioKey = audio.NewAudioKeyProvider(s.ap) - - // init player - s.player, err = player.NewPlayer(s.sp, s.audioKey, s.countryCode, s.app.cfg.AudioDevice, s.app.cfg.VolumeSteps) - if err != nil { - return fmt.Errorf("failed initializing player: %w", err) - } - - return nil -} - -func (s *Session) Close() { - s.stop <- struct{}{} - s.player.Close() - s.audioKey.Close() - s.dealer.Close() - s.ap.Close() -} - -func (s *Session) Run(apiRecv <-chan ApiRequest) { - apRecv := s.ap.Receive(ap.PacketTypeProductInfo, ap.PacketTypeCountryCode) - msgRecv := s.dealer.ReceiveMessage("hm://pusher/v1/connections/", "hm://connect-state/v1/") - reqRecv := s.dealer.ReceiveRequest("hm://connect-state/v1/player/command") - playerRecv := s.player.Receive() - - for { - select { - case <-s.stop: - return - case pkt := <-apRecv: - if err := s.handleAccesspointPacket(pkt.Type, pkt.Payload); err != nil { - log.WithError(err).Warn("failed handling accesspoint packet") - } - case msg := <-msgRecv: - if err := s.handleDealerMessage(msg); err != nil { - log.WithError(err).Warn("failed handling dealer message") - } - case req := <-reqRecv: - if err := s.handleDealerRequest(req); err != nil { - log.WithError(err).Warn("failed handling dealer request") - req.Reply(false) - } else { - log.Debugf("sending successful reply for delaer request") - req.Reply(true) - } - case req := <-apiRecv: - data, err := s.handleApiRequest(req) - req.Reply(data, err) - case ev := <-playerRecv: - s.handlePlayerEvent(&ev) - } - } -} diff --git a/cmd/daemon/state.go b/cmd/daemon/state.go index 6e24bdd..84cb91e 100644 --- a/cmd/daemon/state.go +++ b/cmd/daemon/state.go @@ -59,15 +59,15 @@ func (s *State) playOrigin() string { return s.player.PlayOrigin.FeatureIdentifier } -func (s *Session) initState() { - s.state = &State{ +func (p *AppPlayer) initState() { + p.state = &State{ lastCommand: nil, device: &connectpb.DeviceInfo{ CanPlay: true, Volume: player.MaxStateVolume, - Name: s.app.cfg.DeviceName, - DeviceId: s.app.deviceId, - DeviceType: s.app.deviceType, + Name: p.app.cfg.DeviceName, + DeviceId: p.app.deviceId, + DeviceType: p.app.deviceType, DeviceSoftwareVersion: librespot.VersionString(), ClientId: librespot.ClientId, SpircVersion: "3.2.6", @@ -77,7 +77,7 @@ func (s *Session) initState() { GaiaEqConnectId: true, SupportsLogout: true, IsObservable: true, - VolumeSteps: int32(s.app.cfg.VolumeSteps), + VolumeSteps: int32(p.app.cfg.VolumeSteps), SupportedTypes: []string{"audio/track"}, // TODO: support episodes CommandAcks: true, SupportsRename: false, @@ -99,18 +99,18 @@ func (s *Session) initState() { }, }, } - s.state.reset() + p.state.reset() } -func (s *Session) updateState() { - if err := s.putConnectState(connectpb.PutStateReason_PLAYER_STATE_CHANGED); err != nil { +func (p *AppPlayer) updateState() { + if err := p.putConnectState(connectpb.PutStateReason_PLAYER_STATE_CHANGED); err != nil { log.WithError(err).Error("failed put state after update") } } -func (s *Session) putConnectState(reason connectpb.PutStateReason) error { +func (p *AppPlayer) putConnectState(reason connectpb.PutStateReason) error { if reason == connectpb.PutStateReason_BECAME_INACTIVE { - return s.sp.PutConnectStateInactive(s.spotConnId, false) + return p.sess.Spclient().PutConnectStateInactive(p.spotConnId, false) } putStateReq := &connectpb.PutStateRequest{ @@ -119,24 +119,24 @@ func (s *Session) putConnectState(reason connectpb.PutStateReason) error { PutStateReason: reason, } - if t := s.state.activeSince; !t.IsZero() { + if t := p.state.activeSince; !t.IsZero() { putStateReq.StartedPlayingAt = uint64(t.UnixMilli()) } - if t := s.player.HasBeenPlayingFor(); t > 0 { + if t := p.player.HasBeenPlayingFor(); t > 0 { putStateReq.HasBeenPlayingForMs = uint64(t.Milliseconds()) } - putStateReq.IsActive = s.state.active + putStateReq.IsActive = p.state.active putStateReq.Device = &connectpb.Device{ - DeviceInfo: s.state.device, - PlayerState: s.state.player, + DeviceInfo: p.state.device, + PlayerState: p.state.player, } - if s.state.lastCommand != nil { - putStateReq.LastCommandMessageId = s.state.lastCommand.MessageId - putStateReq.LastCommandSentByDeviceId = s.state.lastCommand.SentByDeviceId + if p.state.lastCommand != nil { + putStateReq.LastCommandMessageId = p.state.lastCommand.MessageId + putStateReq.LastCommandSentByDeviceId = p.state.lastCommand.SentByDeviceId } // finally send the state update - return s.sp.PutConnectState(s.spotConnId, putStateReq) + return p.sess.Spclient().PutConnectState(p.spotConnId, putStateReq) } diff --git a/cmd/daemon/client_token.go b/session/client_token.go similarity index 99% rename from cmd/daemon/client_token.go rename to session/client_token.go index 21392cf..017adf3 100644 --- a/cmd/daemon/client_token.go +++ b/session/client_token.go @@ -1,4 +1,4 @@ -package main +package session import ( "bytes" diff --git a/session/getters.go b/session/getters.go new file mode 100644 index 0000000..4d99968 --- /dev/null +++ b/session/getters.go @@ -0,0 +1,32 @@ +package session + +import ( + "go-librespot/ap" + "go-librespot/audio" + "go-librespot/dealer" + "go-librespot/spclient" +) + +func (s *Session) Username() string { + return s.ap.Username() +} + +func (s *Session) StoredCredentials() []byte { + return s.ap.StoredCredentials() +} + +func (s *Session) Spclient() *spclient.Spclient { + return s.sp +} + +func (s *Session) AudioKey() *audio.KeyProvider { + return s.audioKey +} + +func (s *Session) Dealer() *dealer.Dealer { + return s.dealer +} + +func (s *Session) Accesspoint() *ap.Accesspoint { + return s.ap +} diff --git a/session/options.go b/session/options.go new file mode 100644 index 0000000..37d775d --- /dev/null +++ b/session/options.go @@ -0,0 +1,40 @@ +package session + +import ( + "go-librespot/apresolve" + devicespb "go-librespot/proto/spotify/connectstate/devices" +) + +type Options struct { + // DeviceType is the Spotify showed device type, required. + DeviceType devicespb.DeviceType + // DeviceId is the Spotify device ID, required. + DeviceId string + // Credentials is the credentials to be used for authentication, required. + Credentials any + + // ClientToken is the Spotify client token, leave empty to let the server generate one. + ClientToken string + // Resolver is an instance of apresolve.ApResolver, leave nil to use the default one. + Resolver *apresolve.ApResolver +} + +type UserPassCredentials struct { + Username string + Password string +} + +type SpotifyTokenCredentials struct { + Username string + Token string +} + +type StoredCredentials struct { + Username string + Data []byte +} + +type BlobCredentials struct { + Username string + Blob []byte +} diff --git a/session/session.go b/session/session.go new file mode 100644 index 0000000..946fc10 --- /dev/null +++ b/session/session.go @@ -0,0 +1,130 @@ +package session + +import ( + "encoding/hex" + "fmt" + "go-librespot/ap" + "go-librespot/apresolve" + "go-librespot/audio" + "go-librespot/dealer" + "go-librespot/login5" + devicespb "go-librespot/proto/spotify/connectstate/devices" + credentialspb "go-librespot/proto/spotify/login5/v3/credentials" + "go-librespot/spclient" +) + +type Session struct { + deviceType devicespb.DeviceType + deviceId string + clientToken string + + resolver *apresolve.ApResolver + login5 *login5.Login5 + + ap *ap.Accesspoint + sp *spclient.Spclient + dealer *dealer.Dealer + audioKey *audio.KeyProvider +} + +func NewSessionFromOptions(opts *Options) (*Session, error) { + // validate device type + if opts.DeviceType == devicespb.DeviceType_UNKNOWN { + return nil, fmt.Errorf("missing device type") + } + + // validate device id + if deviceId, err := hex.DecodeString(opts.DeviceId); err != nil { + return nil, fmt.Errorf("invalid device id: %w", err) + } else if len(deviceId) != 20 { + return nil, fmt.Errorf("invalid device id length: %s", opts.DeviceId) + } + + s := Session{ + deviceType: opts.DeviceType, + deviceId: opts.DeviceId, + } + + // use provided client token or retrieve a new one + if len(opts.ClientToken) == 0 { + var err error + s.clientToken, err = retrieveClientToken(s.deviceId) + if err != nil { + return nil, fmt.Errorf("failed obtaining client token: %w", err) + } + } else { + s.clientToken = opts.ClientToken + } + + // use provided resolver or create a new one + if opts.Resolver != nil { + s.resolver = opts.Resolver + } else { + s.resolver = apresolve.NewApResolver() + } + + // create new login5.Login5 + s.login5 = login5.NewLogin5(s.deviceId, s.clientToken) + + // connect to the accesspoint + if apAddr, err := s.resolver.GetAccesspoint(); err != nil { + return nil, fmt.Errorf("failed getting accesspoint from resolver: %w", err) + } else if s.ap, err = ap.NewAccesspoint(apAddr, s.deviceId); err != nil { + return nil, fmt.Errorf("failed initializing accesspoint: %w", err) + } + + // authenticate with the accesspoint using the proper credentials + switch creds := opts.Credentials.(type) { + case StoredCredentials: + if err := s.ap.ConnectStored(creds.Username, creds.Data); err != nil { + return nil, fmt.Errorf("failed authenticating accesspoint with stored credentials: %w", err) + } + case UserPassCredentials: + if err := s.ap.ConnectUserPass(creds.Username, creds.Password); err != nil { + return nil, fmt.Errorf("failed authenticating accesspoint with username and password: %w", err) + } + case SpotifyTokenCredentials: + if err := s.ap.ConnectSpotifyToken(creds.Username, creds.Token); err != nil { + return nil, fmt.Errorf("failed authenticating accesspoint with username and spotify token: %w", err) + } + case BlobCredentials: + if err := s.ap.ConnectBlob(creds.Username, creds.Blob); err != nil { + return nil, fmt.Errorf("failed authenticating accesspoint with blob: %w", err) + } + default: + panic("unknown credentials") + } + + // authenticate with login5 + if err := s.login5.Login(&credentialspb.StoredCredential{ + Username: s.ap.Username(), + Data: s.ap.StoredCredentials(), + }); err != nil { + return nil, fmt.Errorf("failed authenticating with login5: %w", err) + } + + // initialize spclient + if spAddr, err := s.resolver.GetSpclient(); err != nil { + return nil, fmt.Errorf("failed getting spclient from resolver: %w", err) + } else if s.sp, err = spclient.NewSpclient(spAddr, s.login5.AccessToken(), s.deviceId, s.clientToken); err != nil { + return nil, fmt.Errorf("failed initializing spclient: %w", err) + } + + // initialize dealer + if dealerAddr, err := s.resolver.GetDealer(); err != nil { + return nil, fmt.Errorf("failed getting dealer from resolver: %w", err) + } else if s.dealer, err = dealer.NewDealer(dealerAddr, s.login5.AccessToken()); err != nil { + return nil, fmt.Errorf("failed connecting to dealer: %w", err) + } + + // init audio key provider + s.audioKey = audio.NewAudioKeyProvider(s.ap) + + return &s, nil +} + +func (s *Session) Close() { + s.audioKey.Close() + s.dealer.Close() + s.ap.Close() +}