[spotify] Add spotifyCurrentPlaying template function

together with Spotify integration for the bot

Signed-off-by: Knut Ahlers <knut@ahlers.me>
This commit is contained in:
Knut Ahlers 2024-03-15 19:51:00 +01:00
parent caadb6b590
commit 7f4470e692
Signed by: luzifer
SSH key fingerprint: SHA256:/xtE5lCgiRDQr8SLxHMS92ZBlACmATUmF1crK16Ks4E
10 changed files with 412 additions and 1 deletions

View file

@ -441,7 +441,22 @@ Example:
```
# Your int this hour: {{ printf "%.0f" (mulf (seededRandom (list "int" .username (now | date "2006-01-02 15") | join ":")) 100) }}%
< Your int this hour: 66%
< Your int this hour: 88%
```
### `spotifyCurrentPlaying`
Retrieves the current playing track for the given channel
Syntax: `spotifyCurrentPlaying <channel>`
Example:
```
! ^!spotify
> !spotify
# {{ spotifyCurrentPlaying .channel }}
* Beast in Black - Die By The Blade
```
### `streamUptime`

View file

@ -0,0 +1,52 @@
---
title: "Spotify Integration"
---
You are using Spotify and are tired of not working third-party overlays and chat commands? The bot has you covered with its Spotify integration. The integration can retrieve the current playing track and show that in templating as for example through the [EventClient]({{< ref "../overlays/eventclient.md" >}}) and its `renderTemplate` function or the `respond` actor in a rule.
## Setting up
You will need
- a Spotify account
- an instance of the bot with access to the configuration
For this documentation we assume your bots web-interface is available at `https://example.com/` and everywhere you see that below, you need to replace it with your own instance URL.
Start with going to the [Spotify for Developers Dashboard](https://developer.spotify.com/dashboard) and create a new app:
- "App name" is something you can choose yourself
- "App description" is also required, choose yourself
- "Redirect URI" must be `https://example.com/spotify/<channel>` so for exmaple `https://example.com/spotify/luziferus`
- Select "Web API" for the "API/SDKs you are planning to use"
- Check the ToS box (of course after reading those!)
- Click "Save"
- From the "Settings" button of your app get the "Client ID" and "Client secret" and note them down
- Optional: If you need to authorize multiple channels (i.e. for multiple users of the bot instance) you can edit the "Redirect URIs" on the "Settings" page and add more.
Now head into the configuration file and configure the Spotify module:
```yaml
# Module configuration by channel or defining bot-wide defaults. See
# module specific documentation for options to configure in this
# section. All modules come with internal defaults so there is no
# need to configure this but you can overwrite the internal defaults.
module_config:
spotify:
default:
clientId: 'put the client ID you noted down here'
clientSecret: 'put the secret here'
```
Now send the user which currently playing track should be displayed to the `https://example.com/spotify/<channel>` URL. So I for example would visit `https://example.com/spotify/luziferus`. They are redirected to Spotify, need to authorize the app and if everything went well the bot tells them "Spotify is now authorized for this channel, you can close this page".
Now you can for example add a new rule for the channel:
```yaml
- uuid: 0cd18de8-d70b-4651-a51a-3de1a2eb87c5
description: Spotify
actions:
- type: respond
attributes:
message: '{{ spotifyCurrentPlaying .channel }}'
match_message: '!spotify'
```

3
go.mod
View file

@ -26,6 +26,7 @@ require (
github.com/wzshiming/openapi v0.0.0-20200703171632-c7220b3c9cfb
golang.org/x/crypto v0.19.0
golang.org/x/net v0.21.0
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be
gopkg.in/irc.v4 v4.0.0
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.5.4
@ -51,6 +52,7 @@ require (
github.com/go-git/go-billy/v5 v5.5.0 // indirect
github.com/go-jose/go-jose/v3 v3.0.1 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.3.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
@ -97,6 +99,7 @@ require (
golang.org/x/text v0.14.0 // indirect
golang.org/x/time v0.5.0 // indirect
golang.org/x/tools v0.18.0 // indirect
google.golang.org/appengine v1.4.0 // indirect
gopkg.in/validator.v2 v2.0.1 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
modernc.org/libc v1.41.0 // indirect

3
go.sum
View file

@ -90,6 +90,7 @@ github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1 h1:YF8+flBXS5eO826T4nzqPrxfhQThhXl0YzfuUPu4SBg=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
@ -303,6 +304,7 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be h1:vEDujvNQGv4jgYKudGeI/+DAX4Jffq6hpD55MmoEvKs=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -372,6 +374,7 @@ golang.org/x/tools v0.18.0/go.mod h1:GL7B4CwcLLeo59yx/9UWWuNOW1n3VZ4f5axWfML7Lcg
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190404172233-64821d5d2107/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=

View file

@ -0,0 +1,65 @@
package spotify
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/sirupsen/logrus"
"golang.org/x/oauth2"
)
func getCurrentTrackForChannel(channel string) (track string, err error) {
channel = strings.TrimLeft(channel, "#")
conf, err := oauthConfig(channel, "")
if err != nil {
return "", 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 "", fmt.Errorf("loading oauth token: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), spotifyRequestTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.spotify.com/v1/me/player/currently-playing", nil)
if err != nil {
return "", fmt.Errorf("creating currently-playing request: %w", err)
}
resp, err := conf.Client(context.Background(), token).Do(req)
if err != nil {
return "", 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)")
}
}()
defer func() {
if err := db.StoreEncryptedCoreMeta(strings.Join([]string{"spotify-auth", channel}, ":"), token); err != nil {
logrus.WithError(err).Error("storing back Spotify auth token")
}
}()
var payload currentPlayingTrackResponse
if err = json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return "", fmt.Errorf("decoding response: %w", err)
}
var artistNames []string
for _, artist := range payload.Item.Artists {
artistNames = append(artistNames, artist.Name)
}
return strings.Join([]string{
strings.Join(artistNames, ", "),
payload.Item.Name,
}, " - "), nil
}

View file

@ -0,0 +1,76 @@
package spotify
import (
"crypto/sha256"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/gorilla/mux"
"github.com/sirupsen/logrus"
"golang.org/x/oauth2"
)
const spotifyRequestTimeout = 2 * time.Second
func handleStartAuth(w http.ResponseWriter, r *http.Request) {
channel := mux.Vars(r)["channel"]
redirURL := baseURL.ResolveReference(&url.URL{Path: r.URL.Path})
conf, err := oauthConfig(channel, strings.Split(redirURL.String(), "?")[0])
if err != nil {
logrus.WithError(err).Error("getting Spotify oauth config")
http.Error(w, "unable to get Spotify config for this channel", http.StatusInternalServerError)
return
}
code := r.URL.Query().Get("code")
if code == "" {
http.Redirect(
w, r,
conf.AuthCodeURL(fmt.Sprintf("%x", sha256.Sum256(append([]byte(conf.ClientID), []byte(channel)...)))),
http.StatusFound,
)
return
}
token, err := conf.Exchange(r.Context(), r.URL.Query().Get("code"))
if err != nil {
logrus.WithError(err).Error("getting Spotify oauth token")
http.Error(w, "unable to get Spotify auth token", http.StatusInternalServerError)
return
}
if err = db.StoreEncryptedCoreMeta(strings.Join([]string{"spotify-auth", channel}, ":"), token); err != nil {
logrus.WithError(err).Error("storing Spotify oauth token")
http.Error(w, "unable to store Spotify auth token", http.StatusInternalServerError)
return
}
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)
}
clientSecret, err := getModuleConfig(actorName, channel).String("clientSecret")
if err != nil {
return nil, fmt.Errorf("getting clientSecret for channel: %w", err)
}
return &oauth2.Config{
ClientID: clientID,
ClientSecret: clientSecret,
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

@ -0,0 +1,68 @@
// Package spotify contains an actor to query the current playing
// track for a channel with authorized spotify account
package spotify
import (
"fmt"
"net/http"
"net/url"
"github.com/Luzifer/twitch-bot/v3/pkg/database"
"github.com/Luzifer/twitch-bot/v3/plugins"
)
const (
actorName = "spotify"
)
var (
baseURL *url.URL
db database.Connector
getModuleConfig plugins.ModuleConfigGetterFunc
)
// Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) (err error) {
if baseURL, err = url.Parse(args.GetBaseURL()); err != nil {
return fmt.Errorf("parsing base-url: %w", err)
}
db = args.GetDatabaseConnector()
getModuleConfig = args.GetModuleConfigForChannel
args.RegisterTemplateFunction(
"spotifyCurrentPlaying",
plugins.GenericTemplateFunctionGetter(getCurrentTrackForChannel),
plugins.TemplateFuncDocumentation{
Name: "spotifyCurrentPlaying",
Description: "Retrieves the current playing track for the given channel",
Syntax: "spotifyCurrentPlaying <channel>",
Example: &plugins.TemplateFuncDocumentationExample{
MatchMessage: "^!spotify",
MessageContent: "!spotify",
Template: "{{ spotifyCurrentPlaying .channel }}",
FakedOutput: "Beast in Black - Die By The Blade",
},
})
if err = args.RegisterAPIRoute(plugins.HTTPRouteRegistrationArgs{
Description: "Starts authorization of an Spotify Account for a {channel}",
HandlerFunc: handleStartAuth,
Method: http.MethodGet,
Module: actorName,
Name: "Authorize Spotify Account",
Path: "/{channel}",
RequiresWriteAuth: false,
ResponseType: plugins.HTTPRouteResponseTypeTextPlain,
RouteParams: []plugins.HTTPRouteParamDocumentation{
{
Description: "Channel to authorize the Account for",
Name: "channel",
},
},
}); err != nil {
return fmt.Errorf("registering API route: %w", err)
}
return nil
}

View file

@ -0,0 +1,124 @@
package spotify
type (
currentPlayingTrackResponse struct {
Device struct {
ID string `json:"id"`
IsActive bool `json:"is_active"`
IsPrivateSession bool `json:"is_private_session"`
IsRestricted bool `json:"is_restricted"`
Name string `json:"name"`
Type string `json:"type"`
VolumePercent int `json:"volume_percent"`
SupportsVolume bool `json:"supports_volume"`
} `json:"device"`
RepeatState string `json:"repeat_state"`
ShuffleState bool `json:"shuffle_state"`
Context struct {
Type string `json:"type"`
Href string `json:"href"`
ExternalUrls struct {
Spotify string `json:"spotify"`
} `json:"external_urls"`
URI string `json:"uri"`
} `json:"context"`
Timestamp int `json:"timestamp"`
ProgressMs int `json:"progress_ms"`
IsPlaying bool `json:"is_playing"`
Item struct {
Album struct {
AlbumType string `json:"album_type"`
TotalTracks int `json:"total_tracks"`
AvailableMarkets []string `json:"available_markets"`
ExternalUrls struct {
Spotify string `json:"spotify"`
} `json:"external_urls"`
Href string `json:"href"`
ID string `json:"id"`
Images []struct {
URL string `json:"url"`
Height int `json:"height"`
Width int `json:"width"`
} `json:"images"`
Name string `json:"name"`
ReleaseDate string `json:"release_date"`
ReleaseDatePrecision string `json:"release_date_precision"`
Restrictions struct {
Reason string `json:"reason"`
} `json:"restrictions"`
Type string `json:"type"`
URI string `json:"uri"`
Artists []struct {
ExternalUrls struct {
Spotify string `json:"spotify"`
} `json:"external_urls"`
Href string `json:"href"`
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
URI string `json:"uri"`
} `json:"artists"`
} `json:"album"`
Artists []struct {
ExternalUrls struct {
Spotify string `json:"spotify"`
} `json:"external_urls"`
Followers struct {
Href string `json:"href"`
Total int `json:"total"`
} `json:"followers"`
Genres []string `json:"genres"`
Href string `json:"href"`
ID string `json:"id"`
Images []struct {
URL string `json:"url"`
Height int `json:"height"`
Width int `json:"width"`
} `json:"images"`
Name string `json:"name"`
Popularity int `json:"popularity"`
Type string `json:"type"`
URI string `json:"uri"`
} `json:"artists"`
AvailableMarkets []string `json:"available_markets"`
DiscNumber int `json:"disc_number"`
DurationMs int `json:"duration_ms"`
Explicit bool `json:"explicit"`
ExternalIDs struct {
Isrc string `json:"isrc"`
Ean string `json:"ean"`
Upc string `json:"upc"`
} `json:"external_ids"`
ExternalUrls struct {
Spotify string `json:"spotify"`
} `json:"external_urls"`
Href string `json:"href"`
ID string `json:"id"`
IsPlayable bool `json:"is_playable"`
LinkedFrom struct{} `json:"linked_from"`
Restrictions struct {
Reason string `json:"reason"`
} `json:"restrictions"`
Name string `json:"name"`
Popularity int `json:"popularity"`
PreviewURL string `json:"preview_url"`
TrackNumber int `json:"track_number"`
Type string `json:"type"`
URI string `json:"uri"`
IsLocal bool `json:"is_local"`
} `json:"item"`
CurrentlyPlayingType string `json:"currently_playing_type"`
Actions struct {
InterruptingPlayback bool `json:"interrupting_playback"`
Pausing bool `json:"pausing"`
Resuming bool `json:"resuming"`
Seeking bool `json:"seeking"`
SkippingNext bool `json:"skipping_next"`
SkippingPrev bool `json:"skipping_prev"`
TogglingRepeatContext bool `json:"toggling_repeat_context"`
TogglingShuffle bool `json:"toggling_shuffle"`
TogglingRepeatTrack bool `json:"toggling_repeat_track"`
TransferringPlayback bool `json:"transferring_playback"`
} `json:"actions"`
}
)

View file

@ -112,6 +112,8 @@ type (
FormatMessage MsgFormatter
// FrontendNotify is a way to send a notification to the frontend
FrontendNotify func(string)
// GetBaseURL returns the configured BaseURL for the bot
GetBaseURL func() string
// GetDatabaseConnector returns an active database.Connector to access the backend storage database
GetDatabaseConnector func() database.Connector
// GetLogger returns a sirupsen log.Entry pre-configured with the module name

View file

@ -33,6 +33,7 @@ import (
"github.com/Luzifer/twitch-bot/v3/internal/actors/respond"
"github.com/Luzifer/twitch-bot/v3/internal/actors/shield"
"github.com/Luzifer/twitch-bot/v3/internal/actors/shoutout"
"github.com/Luzifer/twitch-bot/v3/internal/actors/spotify"
"github.com/Luzifer/twitch-bot/v3/internal/actors/stopexec"
"github.com/Luzifer/twitch-bot/v3/internal/actors/timeout"
"github.com/Luzifer/twitch-bot/v3/internal/actors/variables"
@ -95,6 +96,7 @@ var (
numeric.Register,
random.Register,
slice.Register,
spotify.Register,
strings.Register,
subscriber.Register,
twitchFns.Register,
@ -159,6 +161,7 @@ func getRegistrationArguments() plugins.RegistrationArguments {
return plugins.RegistrationArguments{
FormatMessage: formatMessage,
FrontendNotify: func(mt string) { frontendNotifyHooks.Ping(mt) },
GetBaseURL: func() string { return cfg.BaseURL },
GetDatabaseConnector: func() database.Connector { return db },
GetLogger: func(moduleName string) *log.Entry { return log.WithField("module", moduleName) },
GetTwitchClient: func() *twitch.Client { return twitchClient },