Compare commits

..

No commits in common. "19a30d342a90afef0600ca5e6e29d3b5f3a4f928" and "5dd6a5323c530e135aec907493f89e7687157879" have entirely different histories.

6 changed files with 37 additions and 132 deletions

View file

@ -1,11 +1,3 @@
# 3.31.0 / 2024-05-13
* Improvements
* [core] Add locking to prevent concurrent rule executions
* Bugfixes
* [spotify] Fix: Refresh-Token gets revoked when using two functions
# 3.30.0 / 2024-04-26
* New Features

View file

@ -467,12 +467,12 @@ Example:
```
# Your int this hour: {{ printf "%.0f" (mulf (seededRandom (list "int" .username (now | date "2006-01-02 15") | join ":")) 100) }}%
< Your int this hour: 82%
< Your int this hour: 72%
```
### `spotifyCurrentPlaying`
Retrieves the current playing track for the given channel (returns an empty string when nothing is playing)
Retrieves the current playing track for the given channel
Syntax: `spotifyCurrentPlaying <channel>`
@ -487,7 +487,7 @@ Example:
### `spotifyLink`
Retrieves the link for the playing track for the given channel (returns an empty string when nothing is playing)
Retrieves the link for the playing track for the given channel
Syntax: `spotifyLink <channel>`

View file

@ -1,101 +0,0 @@
package spotify
import (
"context"
"fmt"
"net/http"
"strings"
"time"
"github.com/Luzifer/twitch-bot/v3/internal/locker"
"github.com/sirupsen/logrus"
"golang.org/x/oauth2"
)
const expiryGrace = 10 * time.Second
func getAuthorizedClient(channel, redirectURL string) (client *http.Client, err error) {
// In templating functions are called multiple times at once which
// with Spotify replacing the refresh-token on each renew would kill
// the stored token when multiple spotify functions are called at
// once. Therefore we do have this method locking itself until it
// has successfully made one request to the users profile and therefore
// renewed the token. The next request then will use the token the
// previous request renewed.
locker.LockByKey(strings.Join([]string{"spotify", "api-access", channel}, ":"))
defer locker.UnlockByKey(strings.Join([]string{"spotify", "api-access", channel}, ":"))
conf, err := oauthConfig(channel, redirectURL)
if err != nil {
return nil, fmt.Errorf("getting oauth config: %w", err)
}
var token *oauth2.Token
if err = db.ReadEncryptedCoreMeta(strings.Join([]string{"spotify-auth", channel}, ":"), &token); err != nil {
return nil, fmt.Errorf("loading oauth token: %w", err)
}
ts := conf.TokenSource(context.Background(), token)
if token.Expiry.After(time.Now().Add(expiryGrace)) {
// Token is still valid long enough, we spare the resources to do
// the profile fetch and directly return the client with the token
// as the scenario described here does not apply.
return oauth2.NewClient(context.Background(), ts), nil
}
logrus.WithField("channel", channel).Debug("refreshing spotify token")
ctx, cancel := context.WithTimeout(context.Background(), spotifyRequestTimeout)
defer cancel()
// We do a request to /me once to refresh the token if needed
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.spotify.com/v1/me", nil)
if err != nil {
return nil, fmt.Errorf("creating currently-playing request: %w", err)
}
oauthClient := oauth2.NewClient(context.Background(), ts)
resp, err := oauthClient.Do(req)
if err != nil {
return nil, fmt.Errorf("executing request: %w", err)
}
defer func() {
if err := resp.Body.Close(); err != nil {
logrus.WithError(err).Error("closing Spotify response body (leaked fd)")
}
}()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("requesting user profile: %w", err)
}
updToken, err := ts.Token()
if err != nil {
return nil, fmt.Errorf("getting updated token: %w", err)
}
if err := db.StoreEncryptedCoreMeta(strings.Join([]string{"spotify-auth", channel}, ":"), updToken); err != nil {
logrus.WithError(err).Error("storing back Spotify auth token")
}
return oauthClient, nil
}
func oauthConfig(channel, redirectURL string) (conf *oauth2.Config, err error) {
clientID, err := getModuleConfig(actorName, channel).String("clientId")
if err != nil {
return nil, fmt.Errorf("getting clientId for channel: %w", err)
}
return &oauth2.Config{
ClientID: clientID,
Endpoint: oauth2.Endpoint{
AuthURL: "https://accounts.spotify.com/authorize",
TokenURL: "https://accounts.spotify.com/api/token",
},
RedirectURL: redirectURL,
Scopes: []string{"user-read-currently-playing"},
}, nil
}

View file

@ -3,25 +3,34 @@ package spotify
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"github.com/sirupsen/logrus"
"golang.org/x/oauth2"
)
var errNotPlaying = errors.New("nothing playing")
func getCurrentTrackForChannel(channel string) (track currentPlayingTrackResponse, err error) {
channel = strings.TrimLeft(channel, "#")
client, err := getAuthorizedClient(channel, "")
conf, err := oauthConfig(channel, "")
if err != nil {
return track, fmt.Errorf("retrieving authorized Spotify client: %w", err)
return track, fmt.Errorf("getting oauth config: %w", err)
}
var token *oauth2.Token
if err = db.ReadEncryptedCoreMeta(strings.Join([]string{"spotify-auth", channel}, ":"), &token); err != nil {
return track, fmt.Errorf("loading oauth token: %w", err)
}
defer func() {
if err := db.StoreEncryptedCoreMeta(strings.Join([]string{"spotify-auth", channel}, ":"), token); err != nil {
logrus.WithError(err).Error("storing back Spotify auth token")
}
}()
ctx, cancel := context.WithTimeout(context.Background(), spotifyRequestTimeout)
defer cancel()
@ -30,7 +39,7 @@ func getCurrentTrackForChannel(channel string) (track currentPlayingTrackRespons
return track, fmt.Errorf("creating currently-playing request: %w", err)
}
resp, err := client.Do(req)
resp, err := conf.Client(context.Background(), token).Do(req)
if err != nil {
return track, fmt.Errorf("executing request: %w", err)
}
@ -49,10 +58,6 @@ func getCurrentTrackForChannel(channel string) (track currentPlayingTrackRespons
case http.StatusOK:
// This is perfect, continue below
case http.StatusNoContent:
// User is not playing anything
return track, errNotPlaying
case http.StatusUnauthorized:
// The token is FUBAR
return track, fmt.Errorf("token expired (HTTP 401 - unauthorized)")
@ -80,10 +85,6 @@ func getCurrentTrackForChannel(channel string) (track currentPlayingTrackRespons
func getCurrentArtistTitleForChannel(channel string) (artistTitle string, err error) {
track, err := getCurrentTrackForChannel(channel)
if err != nil {
if errors.Is(err, errNotPlaying) {
return "", nil
}
return "", fmt.Errorf("getting track info: %w", err)
}
@ -101,10 +102,6 @@ func getCurrentArtistTitleForChannel(channel string) (artistTitle string, err er
func getCurrentLinkForChannel(channel string) (link string, err error) {
track, err := getCurrentTrackForChannel(channel)
if err != nil {
if errors.Is(err, errNotPlaying) {
return "", nil
}
return "", fmt.Errorf("getting track info: %w", err)
}

View file

@ -70,3 +70,20 @@ func handleStartAuth(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Spotify is now authorized for this channel, you can close this page")
}
func oauthConfig(channel, redirectURL string) (conf *oauth2.Config, err error) {
clientID, err := getModuleConfig(actorName, channel).String("clientId")
if err != nil {
return nil, fmt.Errorf("getting clientId for channel: %w", err)
}
return &oauth2.Config{
ClientID: clientID,
Endpoint: oauth2.Endpoint{
AuthURL: "https://accounts.spotify.com/authorize",
TokenURL: "https://accounts.spotify.com/api/token",
},
RedirectURL: redirectURL,
Scopes: []string{"user-read-currently-playing"},
}, nil
}

View file

@ -35,7 +35,7 @@ func Register(args plugins.RegistrationArguments) (err error) {
plugins.GenericTemplateFunctionGetter(getCurrentArtistTitleForChannel),
plugins.TemplateFuncDocumentation{
Name: "spotifyCurrentPlaying",
Description: "Retrieves the current playing track for the given channel (returns an empty string when nothing is playing)",
Description: "Retrieves the current playing track for the given channel",
Syntax: "spotifyCurrentPlaying <channel>",
Example: &plugins.TemplateFuncDocumentationExample{
MatchMessage: "^!spotify",
@ -51,7 +51,7 @@ func Register(args plugins.RegistrationArguments) (err error) {
plugins.GenericTemplateFunctionGetter(getCurrentLinkForChannel),
plugins.TemplateFuncDocumentation{
Name: "spotifyLink",
Description: "Retrieves the link for the playing track for the given channel (returns an empty string when nothing is playing)",
Description: "Retrieves the link for the playing track for the given channel",
Syntax: "spotifyLink <channel>",
Example: &plugins.TemplateFuncDocumentationExample{
MatchMessage: "^!spotifylink",