Преглед на файлове

Create playlists to Spotify automatically, WIP

Toni Fadjukoff преди 6 години
родител
ревизия
cb1e6d0ac3
променени са 6 файла, в които са добавени 314 реда и са изтрити 18 реда
  1. 106 14
      bot.go
  2. 56 0
      db.go
  3. 0 3
      songs.json
  4. 122 1
      spotify/spotify.go
  5. 13 0
      sql/database.sql
  6. 17 0
      web.go

+ 106 - 14
bot.go Целия файл

@@ -10,6 +10,7 @@ import (
10 10
 	"io"
11 11
 	"log"
12 12
 	"os"
13
+	"reflect"
13 14
 	"regexp"
14 15
 	"strconv"
15 16
 	"strings"
@@ -17,8 +18,9 @@ import (
17 18
 )
18 19
 
19 20
 type App struct {
20
-	db          *DB
21
-	credentials Credentials
21
+	db            *DB
22
+	credentials   Credentials
23
+	spotifyClient *spotify.SpotifyClient
22 24
 }
23 25
 
24 26
 type Credentials struct {
@@ -27,18 +29,20 @@ type Credentials struct {
27 29
 	Password            string
28 30
 	SpotifyClientID     string
29 31
 	SpotifyClientSecret string
32
+	SpotifyUser         string
30 33
 	ListenAddr          string
31 34
 }
32 35
 
33 36
 func (app *App) CreateSpotifyClient() *spotify.SpotifyClient {
34 37
 	spotifyClient := spotify.NewClient(app.credentials.SpotifyClientID, app.credentials.SpotifyClientSecret)
38
+	spotifyClient.SetupUserAuthenticate()
35 39
 	return spotifyClient
36 40
 }
37 41
 
38 42
 func (app *App) LaunchWeb() {
39
-	spotifyClient := app.CreateSpotifyClient()
43
+	app.spotifyClient = app.CreateSpotifyClient()
40 44
 	go func() {
41
-		webStart(app.credentials.ListenAddr, app.db, spotifyClient)
45
+		webStart(app.credentials.ListenAddr, app.db, app.spotifyClient)
42 46
 	}()
43 47
 }
44 48
 
@@ -215,7 +219,50 @@ func appendAverages(wikiText string) string {
215 219
 	return strings.Join(changedLines, "\n")
216 220
 }
217 221
 
218
-func (app *App) FixAverages(title string) error {
222
+func findPlaylist(wikiText string) (string, []string) {
223
+	const SONG_MARK = "<!-- Kappale -->"
224
+	const SPOTIFY_MARK = "https://open.spotify.com/track/"
225
+	const SPOTIFY_PLAYLIST_MARK = "/playlist/"
226
+	const PLAYLIST_MARK = " Spotify-soittolista]"
227
+	lines := strings.Split(wikiText, "\n")
228
+
229
+	playlistId := ""
230
+	tracks := make([]string, 0)
231
+	for _, line := range lines {
232
+		if strings.Index(line, SONG_MARK) != -1 {
233
+			i := strings.Index(line, SPOTIFY_MARK)
234
+			if i != -1 {
235
+				j := strings.Index(line[i:], " ")
236
+				if j != -1 {
237
+					j += i
238
+				}
239
+				trackId := line[i+len(SPOTIFY_MARK) : j]
240
+				tracks = append(tracks, trackId)
241
+			}
242
+		} else if strings.Index(line, SPOTIFY_PLAYLIST_MARK) != -1 && strings.Index(line, PLAYLIST_MARK) != -1 {
243
+			i := strings.Index(line, SPOTIFY_PLAYLIST_MARK)
244
+			j := strings.Index(line[i:], PLAYLIST_MARK)
245
+
246
+			playlistId = line[i+len(SPOTIFY_PLAYLIST_MARK) : i+j]
247
+			q := strings.Index(playlistId, "?")
248
+			if q != -1 {
249
+				playlistId = playlistId[:q]
250
+			}
251
+		}
252
+	}
253
+	fmt.Printf("Found playlist %s and tracks %s\n", playlistId, tracks)
254
+
255
+	return playlistId, tracks
256
+}
257
+
258
+func appendPlaylist(wikiText string, playlist *spotify.PlaylistInfo) string {
259
+	changedText := wikiText + `
260
+	[` + playlist.ExternalUrls.Spotify + ` Spotify-soittolista]
261
+	`
262
+	return changedText
263
+}
264
+
265
+func (app *App) AutomateSection(title string) error {
219 266
 	wiki := app.wikiClient()
220 267
 
221 268
 	sections, err := wiki.GetWikiPageSections(title)
@@ -235,18 +282,54 @@ func (app *App) FixAverages(title string) error {
235 282
 			if weekNumber > currentWeek {
236 283
 				break
237 284
 			}
285
+			fmt.Println("Checking section", section.title)
238 286
 
239 287
 			wikiText, err := wiki.GetWikiPageSectionText(title, section.index)
240 288
 			if err != nil {
241 289
 				return err
242 290
 			}
291
+			message := ""
243 292
 			changedWikiText := appendAverages(wikiText)
244 293
 			if changedWikiText != wikiText {
245
-				//fmt.Println(wikiText)
246
-				//fmt.Println(changedWikiText)
294
+				message = message + fmt.Sprintf("Calculate averages for week %d. ", weekNumber)
295
+			}
296
+
297
+			if app.spotifyClient.HasUserLogin() {
298
+				playlistId, tracks := findPlaylist(changedWikiText)
299
+				currentTracks, err := app.db.FindPlaylistBySection(section.title)
300
+				if len(tracks) > 0 && (err != nil || reflect.DeepEqual(currentTracks, tracks)) {
301
+
302
+					spotify := app.spotifyClient
303
+					if playlistId == "" {
304
+
305
+						info, err := spotify.NewPlaylist(title+" "+section.title, app.credentials.SpotifyUser)
306
+						if err != nil {
307
+							log.Println("Error creating playlist")
308
+							return err
309
+						}
310
+						playlistId = info.Id
311
+						changedWikiText = appendPlaylist(changedWikiText, info)
312
+						message = message + fmt.Sprintf("Added link to Spotify playlist for week %d.", weekNumber)
313
+					}
314
+					err := spotify.UpdatePlaylist(app.credentials.SpotifyUser, playlistId, tracks)
315
+					if err != nil {
316
+						log.Println("Error updating playlist")
317
+						return err
318
+					}
319
+					_, err = app.db.UpdatePlaylistBySection(section.title, tracks)
320
+					if err != nil {
321
+						return err
322
+					}
323
+				}
324
+
325
+			}
247 326
 
248
-				_, err := wiki.EditWikiPageSection(title, section.index, changedWikiText,
249
-					fmt.Sprintf("Calculate averages for week %d", weekNumber))
327
+			if message != "" {
328
+				fmt.Println(changedWikiText)
329
+
330
+				//_, err := wiki.EditWikiPageSection(title, section.index, changedWikiText,
331
+				//	fmt.Sprintf("Calculate averages for week %d", weekNumber))
332
+				err = nil
250 333
 				if err != nil {
251 334
 					return err
252 335
 				}
@@ -256,11 +339,20 @@ func (app *App) FixAverages(title string) error {
256 339
 	return nil
257 340
 }
258 341
 
259
-func (app *App) FixAveragesTask() {
260
-	err := app.FixAverages("Levyraati 2018")
342
+func (app *App) AutomateSectionTask() {
343
+	panels, err := app.db.FindAllPanels()
261 344
 	if err != nil {
262
-		fmt.Println("Error while calculating averages:", err)
345
+		fmt.Println("Error while checking db for panels:", err)
346
+		return
347
+	}
348
+	for _, panel := range panels {
349
+		fmt.Println("Checking panel", panel)
350
+		err := app.AutomateSection(panel)
351
+		if err != nil {
352
+			fmt.Println("Error while processing panel:", err)
353
+		}
263 354
 	}
355
+
264 356
 }
265 357
 
266 358
 func initCreds() (Credentials, error) {
@@ -300,10 +392,10 @@ func main() {
300 392
 		panic(err)
301 393
 	}
302 394
 
303
-	a := App{InitDatabase(), creds}
395
+	a := App{InitDatabase(), creds, nil}
304 396
 	a.LaunchWeb()
305 397
 
306
-	gocron.Every(1).Hour().Do(a.FixAveragesTask)
398
+	gocron.Every(1).Hour().Do(a.AutomateSectionTask)
307 399
 	gocron.Every(1).Second().Do(a.SubmitSongs)
308 400
 	<-gocron.Start()
309 401
 

+ 56 - 0
db.go Целия файл

@@ -162,3 +162,59 @@ func (db *DB) UpdateEntry(username, round, artist, title, url string) (bool, err
162 162
 	}
163 163
 	return true, nil
164 164
 }
165
+
166
+func (db *DB) FindAllPanels() ([]string, error) {
167
+	query := `
168
+	SELECT name FROM public.panel`
169
+	rows, err := db.database.Query(query)
170
+	if err != nil {
171
+		return nil, err
172
+	}
173
+	defer rows.Close()
174
+	names := make([]string, 0)
175
+	for i := 0; rows.Next(); i++ {
176
+		var name string
177
+		err := rows.Scan(&name)
178
+		if err != nil {
179
+			return nil, err
180
+		}
181
+		names = append(names, name)
182
+	}
183
+	return names, nil
184
+}
185
+
186
+func (db *DB) FindPlaylistBySection(sectionName string) ([]string, error) {
187
+	query := `
188
+	SELECT pl.tracks FROM public.round_playlist pl 
189
+	JOIN public.round r ON pl.round_id = r.id
190
+	WHERE r.section = $1`
191
+	row := db.database.QueryRow(query, sectionName)
192
+	var tracks []string
193
+	err := row.Scan(&tracks)
194
+	if err != nil {
195
+		return nil, err
196
+	}
197
+	return tracks, nil
198
+}
199
+
200
+func (db *DB) UpdatePlaylistBySection(sectionName string, tracks []string) (bool, error) {
201
+	query := `
202
+	INSERT INTO public.round_playlist pl
203
+	SELECT r.id, $2
204
+	FROM public.round r 
205
+	WHERE r.section = $1
206
+	ON CONFLICT (pl.round_id) DO UPDATE SET tracks = EXCLUDED.tracks`
207
+	res, err := db.database.Exec(query, sectionName, tracks)
208
+
209
+	if err != nil {
210
+		return false, err
211
+	}
212
+	affected, err := res.RowsAffected()
213
+	if err != nil {
214
+		return false, err
215
+	}
216
+	if affected != 1 {
217
+		return false, nil
218
+	}
219
+	return true, nil
220
+}

+ 0 - 3
songs.json Целия файл

@@ -1,3 +0,0 @@
1
-{"Songs":[{"Week":1,"Title":"Party (papiidipaadi)","Artist":"Antti Tuisku feat. Nikke Ankara","URL":"https://open.spotify.com/album/4T5cveHFQ5qZkRfZAJPcP7?si=ll_ktjrpQeW-U9KBOAcgvQ","Sync":true},
2
-    {"Week":2,"Title":"Poro","Artist":"Eläkeläiset","URL":"https://open.spotify.com/track/7BGxxc7n5rGkFhiV50pVC6","Sync":true},
3
-    {"Week":26,"Title":"Summer of '69","Artist":"Bryan Adams","URL":"https://open.spotify.com/track/3bYCGWnZdLQjndiKogqA3G","Sync":false}]}

+ 122 - 1
spotify/spotify.go Целия файл

@@ -2,9 +2,11 @@ package spotify
2 2
 
3 3
 import (
4 4
 	"context"
5
+	"crypto/tls"
5 6
 	"encoding/json"
6 7
 	"errors"
7 8
 	"fmt"
9
+	"golang.org/x/oauth2"
8 10
 	"golang.org/x/oauth2/clientcredentials"
9 11
 	spotifyAuth "golang.org/x/oauth2/spotify"
10 12
 	"io"
@@ -19,6 +21,8 @@ type SpotifyClient struct {
19 21
 	bearerAuth   string
20 22
 	baseURL      *url.URL
21 23
 	httpClient   *http.Client
24
+	config       *oauth2.Config
25
+	context      context.Context
22 26
 }
23 27
 
24 28
 type TrackInfo struct {
@@ -94,6 +98,46 @@ type ErrorInfo struct {
94 98
 	Message string `json:"message"`
95 99
 }
96 100
 
101
+type PlaylistInfo struct {
102
+	Collaborative bool         `json:"collaborative"`
103
+	Description   string       `json:"description"`
104
+	ExternalUrls  ExternalUrls `json:"external_urls"`
105
+	Followers     Followers    `json:"followers"`
106
+	Href          string       `json:"href"`
107
+	Id            string       `json:"id"`
108
+	// TODO images
109
+	Name string `json:"name"`
110
+	// TODO owner
111
+	Public     bool       `json:"public"`
112
+	SnapshotId string     `json:"snapshot_id"`
113
+	Tracks     TracksInfo `json:"tracks"`
114
+	Type       string     `json:"type"`
115
+	Uri        string     `json:"uri"`
116
+}
117
+
118
+type TracksInfo struct {
119
+	Href     string          `json:"href"`
120
+	Items    []PlaylistTrack `json:"items"`
121
+	Limit    int             `json:"limit"`
122
+	Next     string          `json:"next"`
123
+	Offset   int             `json:"offset"`
124
+	Type     string          `json:"type"`
125
+	Previous string          `json:"string"`
126
+	Total    int             `json:"total"`
127
+}
128
+
129
+type PlaylistTrack struct {
130
+	AddedAt string    `json:"added_at"`
131
+	AddedBy string    `json:"added_by"`
132
+	IsLocal bool      `json:"is_local"`
133
+	Track   TrackInfo `json":track"`
134
+}
135
+
136
+type Followers struct {
137
+	Href  string `json:"href"`
138
+	Total int    `json:"total"`
139
+}
140
+
97 141
 func NewClient(username, secret string) *SpotifyClient {
98 142
 	baseURL, _ := url.Parse("https://api.spotify.com")
99 143
 	httpClient := &http.Client{}
@@ -120,6 +164,21 @@ func (s *SpotifyClient) Authenticate() error {
120 164
 	return nil
121 165
 }
122 166
 
167
+func (s *SpotifyClient) SetupUserAuthenticate() error {
168
+	s.config = &oauth2.Config{
169
+		ClientID:     s.clientID,
170
+		ClientSecret: s.clientSecret,
171
+		RedirectURL:  "http://localhost:8080/callback",
172
+		Scopes:       []string{"playlist-modify-public"},
173
+		Endpoint:     spotifyAuth.Endpoint,
174
+	}
175
+	tr := &http.Transport{
176
+		TLSNextProto: map[string]func(authority string, c *tls.Conn) http.RoundTripper{},
177
+	}
178
+	s.context = context.WithValue(context.Background(), oauth2.HTTPClient, &http.Client{Transport: tr})
179
+	return nil
180
+}
181
+
123 182
 func (s *SpotifyClient) GetTrack(trackId string) (*TrackInfo, error) {
124 183
 	var trackInfo TrackInfo
125 184
 	url := s.urlFromBase(fmt.Sprintf("/v1/tracks/%s", trackId))
@@ -131,6 +190,32 @@ func (s *SpotifyClient) GetTrack(trackId string) (*TrackInfo, error) {
131 190
 	}
132 191
 }
133 192
 
193
+func (s *SpotifyClient) NewPlaylist(name, userId string) (*PlaylistInfo, error) {
194
+	var playlistInfo PlaylistInfo
195
+	values := &url.Values{}
196
+	values.Add("name", name)
197
+	url := s.urlFromBase(fmt.Sprintf("/v1/users/%s/playlists", userId))
198
+	err := s.doRequest("POST", url, &playlistInfo, values)
199
+	if err != nil {
200
+		return nil, err
201
+	} else {
202
+		return &playlistInfo, nil
203
+	}
204
+}
205
+
206
+func (s *SpotifyClient) UpdatePlaylist(userId, playlistId string, tracks []string) error {
207
+	values := &url.Values{}
208
+	uris := make([]string, 0)
209
+	for _, track := range tracks {
210
+		uris = append(uris, "spotify:track:"+track)
211
+	}
212
+	values.Add("uris", strings.Join(uris, ","))
213
+
214
+	url := s.urlFromBase(fmt.Sprintf("/v1/users/%s/playlists/%s", userId, playlistId))
215
+	err := s.doRequest("PUT", url, nil, values)
216
+	return err
217
+}
218
+
134 219
 func (s *SpotifyClient) urlFromBase(path string) *url.URL {
135 220
 	endpoint := &url.URL{Path: path}
136 221
 	return s.baseURL.ResolveReference(endpoint)
@@ -147,7 +232,10 @@ func (s *SpotifyClient) doRequest(method string, url *url.URL, object interface{
147 232
 	if err != nil {
148 233
 		return err
149 234
 	}
150
-	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", s.bearerAuth))
235
+
236
+	if s.bearerAuth != "" {
237
+		req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", s.bearerAuth))
238
+	}
151 239
 
152 240
 	if values != nil {
153 241
 		req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
@@ -175,3 +263,36 @@ func (s *SpotifyClient) doRequest(method string, url *url.URL, object interface{
175 263
 	return nil
176 264
 
177 265
 }
266
+
267
+func (s *SpotifyClient) GetAuthUrl(state string) string {
268
+	return s.config.AuthCodeURL(state)
269
+}
270
+
271
+func (s *SpotifyClient) HandleToken(state string, r *http.Request) error {
272
+	values := r.URL.Query()
273
+	if e := values.Get("error"); e != "" {
274
+		return errors.New("spotify: auth failed - " + e)
275
+	}
276
+	code := values.Get("code")
277
+	if code == "" {
278
+		return errors.New("spotify: didn't get access code")
279
+	}
280
+	actualState := values.Get("state")
281
+	if actualState != state {
282
+		return errors.New("spotify: redirect state parameter doesn't match")
283
+	}
284
+	token, err := s.config.Exchange(s.context, code)
285
+	if err != nil {
286
+		return err
287
+	}
288
+
289
+	fmt.Println("Got token from Spotify", token)
290
+
291
+	client := s.config.Client(s.context, token)
292
+	s.httpClient = client
293
+	return nil
294
+}
295
+
296
+func (s *SpotifyClient) HasUserLogin() bool {
297
+	return s.httpClient != nil && s.config != nil && s.context != nil
298
+}

+ 13 - 0
sql/database.sql Целия файл

@@ -75,3 +75,16 @@ CREATE TABLE public.entry
75 75
     synced boolean NOT NULL DEFAULT false,
76 76
     CONSTRAINT entry_pkey PRIMARY KEY (user_id, round_id)
77 77
 );
78
+
79
+
80
+-- Table: public.round_playlist
81
+
82
+-- DROP TABLE public.round_playlist;
83
+
84
+CREATE TABLE public.round_playlist
85
+(
86
+    round_id int,
87
+    tracks text[],
88
+    CONSTRAINT round_id FOREIGN KEY (round_id)
89
+        REFERENCES public.round (id)
90
+);

+ 17 - 0
web.go Целия файл

@@ -149,6 +149,21 @@ func (s *WebService) LoginHandler(w http.ResponseWriter, r *http.Request) {
149 149
 	http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
150 150
 }
151 151
 
152
+func (s *WebService) SpotifyHandler(w http.ResponseWriter, r *http.Request) {
153
+	url := s.spotifyClient.GetAuthUrl("foobar")
154
+	http.Redirect(w, r, url, http.StatusTemporaryRedirect)
155
+}
156
+
157
+func (s *WebService) CallbackHandler(w http.ResponseWriter, r *http.Request) {
158
+	state := "foobar"
159
+	err := s.spotifyClient.HandleToken(state, r)
160
+	if err != nil {
161
+		http.Error(w, "Couldn't get token", http.StatusForbidden)
162
+		return
163
+	}
164
+	http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
165
+}
166
+
152 167
 type WebService struct {
153 168
 	db            *DB
154 169
 	spotifyClient *spotify.SpotifyClient
@@ -168,6 +183,8 @@ func webStart(listenAddr string, db *DB, spotifyClient *spotify.SpotifyClient) {
168 183
 	mux.HandleFunc("/", service.IndexHandler)
169 184
 	mux.HandleFunc("/update", service.UpdateHandler)
170 185
 	mux.HandleFunc("/login", service.LoginHandler)
186
+	mux.HandleFunc("/spotify", service.SpotifyHandler)
187
+	mux.HandleFunc("/callback", service.CallbackHandler)
171 188
 
172 189
 	http.ListenAndServe(listenAddr, mux)
173 190
 }