Skip to content

Commit

Permalink
Cache session token
Browse files Browse the repository at this point in the history
  • Loading branch information
nlowe committed Feb 17, 2024
1 parent 33779d6 commit 051453a
Show file tree
Hide file tree
Showing 6 changed files with 165 additions and 33 deletions.
66 changes: 54 additions & 12 deletions cmd/root.go
Expand Up @@ -7,6 +7,9 @@ import (
"os/signal"
"os/user"
"path/filepath"
"strings"

"github.com/nlowe/pianoman/lazy"

"github.com/sirupsen/logrus"
"github.com/spf13/cobra"
Expand All @@ -20,8 +23,6 @@ import (

func NewRootCmd() *cobra.Command {
var cfg config.Config
var w wal.WAL[pianobar.Track]
var lfm *lastfm.API

result := &cobra.Command{
Use: "pianoman <eventcmd>",
Expand Down Expand Up @@ -54,31 +55,72 @@ func NewRootCmd() *cobra.Command {
return fmt.Errorf("failed to parse config: %w", err)
}

cfg.Path = configFilePath

// Update Logger
lvl, err := logrus.ParseLevel(cfg.Verbosity)
if err != nil {
return fmt.Errorf("failed to configure logging verbosity: %w", err)
}
logrus.SetLevel(lvl)

return err
},
RunE: func(_ *cobra.Command, args []string) error {
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()

// Normalize WAL Path
w, err = wal.Open[pianobar.Track](filepath.Join(
filepath.Dir(configFilePath), filepath.Clean(cfg.Scrobble.WALDirectory),
w, err := wal.Open[pianobar.Track](filepath.Join(
filepath.Dir(cfg.Path), filepath.Clean(cfg.Scrobble.WALDirectory),
), lastfm.MaxTracksPerScrobble)

lfm = lastfm.New(
if err != nil {
return fmt.Errorf("failed to open wal: %w", err)
}

sessionTokenCachePath := filepath.Join(
filepath.Dir(cfg.Path), "session",
)

sessionTokenCache := lazy.New[string](func() {
logrus.Debug("Deleting Session Token")
_ = os.Remove(sessionTokenCachePath)
})

defer func() {
token := sessionTokenCache.Fetch(func() string {
return ""
})

if token == "" {
logrus.Warn("No token to cache")
return
}

// Try to cache the token
logrus.Debug("Caching session token")
if err = os.WriteFile(sessionTokenCachePath, []byte(token), 0o600); err != nil {
logrus.WithError(err).Error("Failed to cache session token")
}
}()

cachedToken, err := os.ReadFile(sessionTokenCachePath)
if err == nil {
logrus.Debug("Using cached session token")
_ = sessionTokenCache.Fetch(func() string {
return strings.TrimSpace(string(cachedToken))
})
}

lfm := lastfm.New(
sessionTokenCache,
cfg.Auth.API.Key,
cfg.Auth.API.Secret,
cfg.Auth.User.Name,
cfg.Auth.User.Password,
)

return err
},
RunE: func(_ *cobra.Command, args []string) error {
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()

flags := eventcmd.HandleSongFinish
if cfg.Scrobble.NowPlaying {
flags |= eventcmd.HandleSongStart
Expand All @@ -92,7 +134,7 @@ func NewRootCmd() *cobra.Command {
// TODO: Don't scrobble thumbs down if configured

// TODO: Support eventcmd chaining
_, err := eventcmd.Handle(ctx, args[0], flags, os.Stdin, w, lfm, lfm)
_, err = eventcmd.Handle(ctx, args[0], flags, os.Stdin, w, lfm, lfm)
return err
},
}
Expand Down
2 changes: 2 additions & 0 deletions internal/config/config.go
Expand Up @@ -15,6 +15,8 @@ type Config struct {
EventCMD EventConfig `yaml:"eventcmd"`

Verbosity string `yaml:"verbosity"`

Path string `yaml:"-"`
}

type AuthConfig struct {
Expand Down
35 changes: 22 additions & 13 deletions lastfm/scrobbler.go
Expand Up @@ -6,11 +6,12 @@ import (
"fmt"
"net/http"
"strconv"
"sync"

"github.com/hashicorp/go-cleanhttp"
"github.com/sirupsen/logrus"

"github.com/nlowe/pianoman/lazy"

"github.com/nlowe/pianoman/pianobar"
)

Expand Down Expand Up @@ -68,24 +69,24 @@ type FeedbackProvider interface {
type API struct {
api *http.Client

getSessionKey *sync.Once
sessionKeyCache *lazy.Value[string]
sessionKey string

sessionKey string
apiKey string
apiSecret string
username string
password string
apiKey string
apiSecret string
username string
password string
}

// Ensure API implements Scrobbler and FeedbackProvider
var _ Scrobbler = (*API)(nil)
var _ FeedbackProvider = (*API)(nil)

func New(key, secret, username, password string) *API {
func New(cache *lazy.Value[string], key, secret, username, password string) *API {
return &API{
api: cleanhttp.DefaultClient(),

getSessionKey: &sync.Once{},
sessionKeyCache: cache,

apiKey: key,
apiSecret: secret,
Expand Down Expand Up @@ -114,12 +115,22 @@ func sendAndCheck[TResult any](ctx context.Context, a *API, params Request) (Res
return result, fmt.Errorf("sendAndCheck: failed to make request: %w", err)
}

// Zero the session on common http auth failure codes
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusUnauthorized {
a.sessionKeyCache.Zero()
}

// Decode Response
log.Tracef("%s finished with %s", params.method(), resp.Status)
if err = xml.NewDecoder(resp.Body).Decode(&result); err != nil {
return result, fmt.Errorf("sendAndCheck: request failed: failed to parse response: %s: %w", resp.Status, err)
}

// If we get any error, expire the session so we get a fresh one next time
if result.Error != nil {
a.sessionKeyCache.Zero()
}

// Check Response
log.Tracef("Last.FM returned %s in response to %s", result.Status, params.method())
if result.Status == statusFailed {
Expand All @@ -134,9 +145,7 @@ func sendAndCheck[TResult any](ctx context.Context, a *API, params Request) (Res
}

func (a *API) ensureSessionKey(ctx context.Context) {
a.getSessionKey.Do(func() {
// TODO: Cache session key?

a.sessionKey = a.sessionKeyCache.Fetch(func() string {
log.Debugf("Logging into Last.FM as %s", a.username)
params := newRequest(methodGetMobileSession)
params.set(paramApiKey, a.apiKey)
Expand All @@ -148,7 +157,7 @@ func (a *API) ensureSessionKey(ctx context.Context) {
log.WithError(err).Fatal("Failed to login to Last.FM")
}

a.sessionKey = resp.Value.Key
return resp.Value.Key
})
}

Expand Down
21 changes: 13 additions & 8 deletions lastfm/scrobbler_test.go
Expand Up @@ -7,10 +7,11 @@ import (
"net/http"
"net/url"
"strings"
"sync"
"testing"
"time"

"github.com/nlowe/pianoman/lazy"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

Expand Down Expand Up @@ -39,15 +40,19 @@ func (r roundTripperFunc) RoundTrip(request *http.Request) (*http.Response, erro
func setupAPI(t *testing.T, f func(r *http.Request) *http.Response) *API {
t.Helper()

once := &sync.Once{}
once.Do(func() {})
tokenCache := lazy.New[string](func() {})

_ = tokenCache.Fetch(func() string {
return testSessionKey
})

return &API{
api: &http.Client{Transport: roundTripperFunc(f)},
getSessionKey: once,
sessionKey: testSessionKey,
apiKey: testApiKey,
apiSecret: testApiSecret,
api: &http.Client{Transport: roundTripperFunc(f)},

sessionKeyCache: tokenCache,

apiKey: testApiKey,
apiSecret: testApiSecret,
}
}

Expand Down
39 changes: 39 additions & 0 deletions lazy/value.go
@@ -0,0 +1,39 @@
package lazy

import (
"sync"
)

// Value is a simple wrapper around sync.Once that can lazy load a value
// Once Zero is called, this value always returns the zero value for T.
type Value[T any] struct {
once *sync.Once

value T

onZero func()
}

// New creates a new Value. The provided onZero function will be called when Zero is called
func New[T any](onZero func()) *Value[T] {
return &Value[T]{
once: &sync.Once{},
onZero: onZero,
}
}

// Fetch returns the lazy value, calling populate to fetch it the first time
func (c *Value[T]) Fetch(populate func() T) T {
c.once.Do(func() {
c.value = populate()
})

return c.value
}

// Zero causes any future calls to Fetch to return the zero value for this function
func (c *Value[T]) Zero() {
var v T
c.value = v
c.onZero()
}
35 changes: 35 additions & 0 deletions lazy/value_test.go
@@ -0,0 +1,35 @@
package lazy

import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestValue(t *testing.T) {
var reset bool
sut := New[string](func() {
reset = true
})

assert.False(t, reset, "cache was reset unexpectedly")

assert.Equal(t, "a", sut.Fetch(func() string {
return "a"
}))

assert.False(t, reset, "cache was reset unexpectedly")

assert.Equal(t, "a", sut.Fetch(func() string {
return "b"
}))

assert.False(t, reset, "cache was reset unexpectedly")

sut.Zero()
assert.True(t, reset, "expected the cache to be reset")

assert.Zero(t, sut.Fetch(func() string {
return "c"
}))
}

0 comments on commit 051453a

Please sign in to comment.