drum 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,50 @@
1
+ require 'drum/model/artist'
2
+ require 'drum/model/ref'
3
+ require 'drum/model/playlist'
4
+ require 'drum/model/track'
5
+ require 'drum/service/service'
6
+
7
+ module Drum
8
+ # A service that provides a mock playlist for ease of manual testing.
9
+ class MockService < Service
10
+ def name
11
+ 'mock'
12
+ end
13
+
14
+ def parse_ref(raw_ref)
15
+ if raw_ref.is_token && raw_ref.text == 'mock'
16
+ Ref.new(self.name, :playlist, '')
17
+ else
18
+ nil
19
+ end
20
+ end
21
+
22
+ def download(playlist_ref)
23
+ if playlist_ref.resource_type == :playlist
24
+ [Playlist.new(
25
+ id: '95d5e24cde85a09ce2ac0ae381179dabacee0202',
26
+ name: 'Mock Playlist',
27
+ description: 'Lots of great songs',
28
+ author_id: '0',
29
+ users: {
30
+ '0' => User.new(id: '0', display_name: 'Mr. X')
31
+ },
32
+ artists: {
33
+ '0' => Artist.new(id: '0', name: 'Queen'),
34
+ '1' => Artist.new(id: '1', name: 'The Beatles')
35
+ },
36
+ tracks: [
37
+ Track.new(name: 'Bohemian Rhapsody', artist_ids: ['0']),
38
+ Track.new(name: 'Let it be', artist_ids: ['1'])
39
+ ],
40
+ path: [
41
+ 'Top',
42
+ 'Sub'
43
+ ]
44
+ )]
45
+ else
46
+ []
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,43 @@
1
+ # A wrapper around a music streaming service's API providing methods
2
+ # for downloading/uploading playlists.
3
+ class Drum::Service
4
+ # The service's internal name used to identify it.
5
+ #
6
+ # @return [String] The internal name of the service.
7
+ def name
8
+ raise "ERROR: Service does not specify a name!"
9
+ end
10
+
11
+ # Tries to parse a ref from this service.
12
+ #
13
+ # @param [RawRef] raw_ref The raw reference to be parsed.
14
+ # @return [optional, Ref] The ref, if parsed successfully, otherwise nil
15
+ def parse_ref(raw_ref)
16
+ nil
17
+ end
18
+
19
+ # TODO: Update docs to be more general (e.g. ref instead of playlist_ref)
20
+
21
+ # Removes playlists from this service.
22
+ #
23
+ # @param [Ref] playlist_ref A ref to the playlists (see README for examples)
24
+ def remove(playlist_ref)
25
+ raise "ERROR: Service '#{self.name}' cannot remove playlists (yet)!"
26
+ end
27
+
28
+ # Downloads playlists from this service.
29
+ #
30
+ # @param [Ref] playlist_ref A ref to the playlists (see README for examples)
31
+ # @return [Array<Playlist>] The playlists downloaded
32
+ def download(playlist_ref)
33
+ raise "ERROR: Service '#{self.name}' cannot download playlists (yet)!"
34
+ end
35
+
36
+ # Uploads playlists to this service.
37
+ #
38
+ # @param [Ref] playlist_ref A ref to the upload location (see README for examples)
39
+ # @param [Array<Playlist>, Enumerator<Playlist>] playlists The list of playlists to be uploaded
40
+ def upload(playlist_ref, playlists)
41
+ raise "ERROR: Service '#{self.name}' cannot upload playlists (yet)!"
42
+ end
43
+ end
@@ -0,0 +1,615 @@
1
+ require 'drum/model/album'
2
+ require 'drum/model/playlist'
3
+ require 'drum/model/user'
4
+ require 'drum/model/track'
5
+ require 'drum/model/ref'
6
+ require 'drum/service/service'
7
+ require 'drum/utils/persist'
8
+ require 'drum/utils/log'
9
+ require 'base64'
10
+ require 'date'
11
+ require 'digest'
12
+ require 'json'
13
+ require 'launchy'
14
+ require 'rest-client'
15
+ require 'rspotify'
16
+ require 'ruby-limiter'
17
+ require 'progress_bar'
18
+ require 'securerandom'
19
+ require 'uri'
20
+ require 'webrick'
21
+
22
+ module Drum
23
+ # A service implementation that uses the Spotify Web API to query playlists.
24
+ class SpotifyService < Service
25
+ include Log
26
+ extend Limiter::Mixin
27
+
28
+ PLAYLISTS_CHUNK_SIZE = 50
29
+ TRACKS_CHUNK_SIZE = 100
30
+ SAVED_TRACKS_CHUNKS_SIZE = 50
31
+ TO_SPOTIFY_TRACKS_CHUNK_SIZE = 50
32
+ UPLOAD_PLAYLIST_TRACKS_CHUNK_SIZE = 100
33
+
34
+ CLIENT_ID_VAR = 'SPOTIFY_CLIENT_ID'
35
+ CLIENT_SECRET_VAR = 'SPOTIFY_CLIENT_SECRET'
36
+
37
+ # Rate-limiting for API-heavy methods
38
+ # 'rate' describes the max. number of calls per interval (seconds)
39
+
40
+ limit_method :extract_sp_features, rate: 15, interval: 5
41
+ limit_method :all_sp_playlist_tracks, rate: 15, interval: 5
42
+ limit_method :all_sp_library_tracks, rate: 15, interval: 5
43
+ limit_method :all_sp_library_playlists, rate: 15, interval: 5
44
+ limit_method :to_sp_track, rate: 15, interval: 5
45
+ limit_method :to_sp_tracks, rate: 15, interval: 5
46
+ limit_method :upload_sp_playlist_tracks, rate: 15, interval: 5
47
+ limit_method :upload_playlist, rate: 15, interval: 5
48
+
49
+ # Initializes the Spotify service.
50
+ #
51
+ # @param [String] cache_dir The path to the cache directory (shared by all services)
52
+ # @param [Boolean] fetch_artist_images Whether to fetch artist images (false by default)
53
+ def initialize(cache_dir, fetch_artist_images: false)
54
+ @cache_dir = cache_dir / self.name
55
+ @cache_dir.mkdir unless @cache_dir.directory?
56
+
57
+ @auth_tokens = PersistentHash.new(@cache_dir / 'auth-tokens.yaml')
58
+ @authenticated = false
59
+
60
+ @fetch_artist_images = fetch_artist_images
61
+ end
62
+
63
+ def name
64
+ 'spotify'
65
+ end
66
+
67
+ # Authentication
68
+
69
+ def authenticate_app(client_id, client_secret)
70
+ RSpotify.authenticate(client_id, client_secret)
71
+ end
72
+
73
+ def consume_authentication_response(auth_response)
74
+ unless auth_response.code >= 200 && auth_response.code < 300
75
+ raise "Something went wrong while fetching auth token: #{auth_response}"
76
+ end
77
+
78
+ auth_json = JSON.parse(auth_response.body)
79
+ access_token = auth_json['access_token']
80
+ refresh_token = auth_json['refresh_token']
81
+ token_type = auth_json['token_type']
82
+ expires_in = auth_json['expires_in'] # seconds
83
+ expires_at = DateTime.now + (expires_in / 86400.0)
84
+
85
+ @auth_tokens[:latest] = {
86
+ access_token: access_token,
87
+ refresh_token: refresh_token || @auth_tokens[:latest][:refresh_token],
88
+ token_type: token_type,
89
+ expires_at: expires_at
90
+ }
91
+ log.info "Successfully added access token that expires at #{expires_at}."
92
+
93
+ [access_token, refresh_token, token_type]
94
+ end
95
+
96
+ def authenticate_user_via_browser(client_id, client_secret)
97
+ # Generate a new access refresh token,
98
+ # this might require user interaction. Since the
99
+ # user has to authenticate through the browser
100
+ # via Spotify's website, we use a small embedded
101
+ # HTTP server as a 'callback'.
102
+
103
+ port = 17998
104
+ server = WEBrick::HTTPServer.new Port: port
105
+ csrf_state = SecureRandom.hex
106
+ auth_code = nil
107
+ error = nil
108
+
109
+ server.mount_proc '/callback' do |req, res|
110
+ error = req.query['error']
111
+ auth_code = req.query['code']
112
+ csrf_response = req.query['state']
113
+
114
+ if error.nil? && !auth_code.nil? && csrf_response == csrf_state
115
+ res.body = 'Successfully got authorization code!'
116
+ else
117
+ res.body = "Could not authorize: #{error} Sorry :("
118
+ end
119
+
120
+ server.shutdown
121
+ end
122
+
123
+ scopes = [
124
+ # Listening History
125
+ 'user-read-recently-played',
126
+ 'user-top-read',
127
+ # Playlists
128
+ 'playlist-modify-private',
129
+ 'playlist-read-private',
130
+ 'playlist-read-collaborative',
131
+ # Library
132
+ 'user-library-modify',
133
+ 'user-library-read',
134
+ # User
135
+ 'user-read-private'
136
+ ]
137
+ authorize_url = "https://accounts.spotify.com/authorize?client_id=#{client_id}&response_type=code&redirect_uri=http%3A%2F%2Flocalhost:#{port}%2Fcallback&scope=#{scopes.join('%20')}&state=#{csrf_state}"
138
+ Launchy.open(authorize_url)
139
+
140
+ trap 'INT' do server.shutdown end
141
+
142
+ log.info "Launching callback HTTP server on port #{port}, waiting for auth code..."
143
+ server.start
144
+
145
+ if auth_code.nil?
146
+ raise "Did not get an auth code: #{error}"
147
+ end
148
+
149
+ auth_response = RestClient.post('https://accounts.spotify.com/api/token', {
150
+ grant_type: 'authorization_code',
151
+ code: auth_code,
152
+ redirect_uri: "http://localhost:#{port}/callback", # validation only
153
+ client_id: client_id,
154
+ client_secret: client_secret
155
+ })
156
+
157
+ self.consume_authentication_response(auth_response)
158
+ end
159
+
160
+ def authenticate_user_via_refresh(client_id, client_secret, refresh_token)
161
+ # Authenticate the user using an existing (cached)
162
+ # refresh token. This is useful if the user already
163
+ # has been authenticated or a non-interactive authentication
164
+ # is required (e.g. in a CI script).
165
+ encoded = Base64.strict_encode64("#{client_id}:#{client_secret}")
166
+ auth_response = RestClient.post('https://accounts.spotify.com/api/token', {
167
+ grant_type: 'refresh_token',
168
+ refresh_token: refresh_token
169
+ }, {
170
+ 'Authorization' => "Basic #{encoded}"
171
+ })
172
+
173
+ self.consume_authentication_response(auth_response)
174
+ end
175
+
176
+ def authenticate_user(client_id, client_secret)
177
+ existing = @auth_tokens[:latest]
178
+
179
+ unless existing.nil? || existing[:expires_at].nil? || existing[:expires_at] < DateTime.now
180
+ log.info 'Skipping authentication...'
181
+ return existing[:access_token], existing[:refresh_token], existing[:token_type]
182
+ end
183
+
184
+ unless existing.nil? || existing[:refresh_token].nil?
185
+ log.info 'Authenticating via refresh...'
186
+ self.authenticate_user_via_refresh(client_id, client_secret, existing[:refresh_token])
187
+ else
188
+ log.info 'Authenticating via browser...'
189
+ self.authenticate_user_via_browser(client_id, client_secret)
190
+ end
191
+ end
192
+
193
+ def fetch_me(access_token, token_type)
194
+ auth_response = RestClient.get('https://api.spotify.com/v1/me', {
195
+ Authorization: "#{token_type} #{access_token}"
196
+ })
197
+
198
+ unless auth_response.code >= 200 && auth_response.code < 300
199
+ raise "Something went wrong while user data: #{auth_response}"
200
+ end
201
+
202
+ return JSON.parse(auth_response.body)
203
+ end
204
+
205
+ def authenticate
206
+ if @authenticated
207
+ return
208
+ end
209
+
210
+ client_id = ENV[CLIENT_ID_VAR]
211
+ client_secret = ENV[CLIENT_SECRET_VAR]
212
+
213
+ if client_id.nil? || client_secret.nil?
214
+ raise "Please specify the env vars #{CLIENT_ID_VAR} and #{CLIENT_SECRET_VAR}!"
215
+ end
216
+
217
+ self.authenticate_app(client_id, client_secret)
218
+ access_token, refresh_token, token_type = self.authenticate_user(client_id, client_secret)
219
+
220
+ me_json = self.fetch_me(access_token, token_type)
221
+ me_json['credentials'] = {
222
+ 'token' => access_token,
223
+ 'refresh_token' => refresh_token,
224
+ 'access_refresh_callback' => Proc.new do |new_token, token_lifetime|
225
+ new_expiry = DateTime.now + (token_lifetime / 86400.0)
226
+ @auth_tokens[:latest] = {
227
+ access_token: new_token,
228
+ refresh_token: refresh_token, # TODO: Refresh token might change too
229
+ token_type: token_type,
230
+ expires_at: new_expiry
231
+ }
232
+ end
233
+ }
234
+
235
+ @me = RSpotify::User.new(me_json)
236
+ @authenticated = true
237
+
238
+ log.info "Successfully logged in to Spotify API as #{me_json['id']}."
239
+ end
240
+
241
+ # Download helpers
242
+
243
+ def all_sp_library_playlists(offset: 0)
244
+ sp_playlists = @me.playlists(limit: PLAYLISTS_CHUNK_SIZE, offset: offset)
245
+ unless sp_playlists.empty?
246
+ sp_playlists + self.all_sp_library_playlists(offset: offset + PLAYLISTS_CHUNK_SIZE)
247
+ else
248
+ []
249
+ end
250
+ end
251
+
252
+ def all_sp_playlist_tracks(sp_playlist, offset: 0)
253
+ sp_tracks = sp_playlist.tracks(limit: TRACKS_CHUNK_SIZE, offset: offset)
254
+ unless sp_tracks.empty?
255
+ sp_tracks + self.all_sp_playlist_tracks(sp_playlist, offset: offset + TRACKS_CHUNK_SIZE)
256
+ else
257
+ []
258
+ end
259
+ end
260
+
261
+ def all_sp_library_tracks(offset: 0)
262
+ sp_tracks = @me.saved_tracks(limit: SAVED_TRACKS_CHUNKS_SIZE, offset: offset)
263
+ unless sp_tracks.empty?
264
+ sp_tracks + self.all_sp_library_tracks(offset: offset + SAVED_TRACKS_CHUNKS_SIZE)
265
+ else
266
+ []
267
+ end
268
+ end
269
+
270
+ def extract_sp_features(sp_track)
271
+ sp_track&.audio_features
272
+ end
273
+
274
+ # Note that while the `from_sp_*` methods use
275
+ # an existing `new_playlist` to reuse albums/artists/etc,
276
+ # they do not mutate the `new_playlist` themselves.
277
+
278
+ # TODO: Replace hexdigest id generation with something
279
+ # that matches e.g. artists or albums with those
280
+ # already in the playlist.
281
+
282
+ def from_sp_id(sp_id, new_playlist)
283
+ sp_id.try { |i| Digest::SHA1.hexdigest(sp_id) }
284
+ end
285
+
286
+ def from_sp_album(sp_album, new_playlist)
287
+ new_id = self.from_sp_id(sp_album.id, new_playlist)
288
+ new_album = new_playlist.albums[new_id]
289
+ unless new_album.nil?
290
+ return [new_album, []]
291
+ end
292
+
293
+ new_album = Album.new(
294
+ id: self.from_sp_id(sp_album.id, new_playlist),
295
+ name: sp_album.name,
296
+ spotify: AlbumSpotify.new(
297
+ id: sp_album.id,
298
+ image_url: sp_album&.images.first&.dig('url')
299
+ )
300
+ )
301
+
302
+ new_artists = sp_album.artists.map do |sp_artist|
303
+ new_artist = self.from_sp_artist(sp_artist, new_playlist)
304
+ new_album.artist_ids << new_artist.id
305
+ new_artist
306
+ end
307
+
308
+ [new_album, new_artists]
309
+ end
310
+
311
+ def from_sp_track(sp_track, new_playlist)
312
+ new_track = Track.new(
313
+ name: sp_track.name,
314
+ duration_ms: sp_track.duration_ms,
315
+ explicit: sp_track.explicit,
316
+ isrc: sp_track.external_ids&.dig('isrc'),
317
+ spotify: TrackSpotify.new(
318
+ id: sp_track.id
319
+ )
320
+ )
321
+
322
+ new_artists = sp_track.artists.map do |sp_artist|
323
+ new_artist = self.from_sp_artist(sp_artist, new_playlist)
324
+ new_track.artist_ids << new_artist.id
325
+ new_artist
326
+ end
327
+
328
+ new_album, new_album_artists = self.from_sp_album(sp_track.album, new_playlist)
329
+ new_track.album_id = new_album.id
330
+ new_artists += new_album_artists
331
+
332
+ # TODO: Audio features
333
+
334
+ [new_track, new_artists, new_album]
335
+ end
336
+
337
+ def from_sp_artist(sp_artist, new_playlist)
338
+ new_id = self.from_sp_id(sp_artist.id, new_playlist)
339
+ new_playlist.artists[new_id] || Artist.new(
340
+ id: new_id,
341
+ name: sp_artist.name,
342
+ spotify: ArtistSpotify.new(
343
+ id: sp_artist.id,
344
+ image_url: if @fetch_artist_images
345
+ sp_artist&.images.first&.dig('url')
346
+ else
347
+ nil
348
+ end
349
+ )
350
+ )
351
+ end
352
+
353
+ def from_sp_user(sp_user, new_playlist)
354
+ new_id = self.from_sp_id(sp_user.id, new_playlist)
355
+ new_playlist.users[new_id] || User.new(
356
+ id: self.from_sp_id(sp_user.id, new_playlist),
357
+ display_name: begin
358
+ sp_user.display_name unless sp_user.id.empty?
359
+ rescue StandardError => e
360
+ nil
361
+ end,
362
+ spotify: UserSpotify.new(
363
+ id: sp_user.id,
364
+ image_url: begin
365
+ sp_user&.images.first&.dig('url')
366
+ rescue StandardError => e
367
+ nil
368
+ end
369
+ )
370
+ )
371
+ end
372
+
373
+ def from_sp_playlist(sp_playlist, sp_tracks = nil)
374
+ new_playlist = Playlist.new(
375
+ name: sp_playlist.name,
376
+ description: sp_playlist&.description,
377
+ spotify: PlaylistSpotify.new(
378
+ id: sp_playlist.id,
379
+ public: sp_playlist.public,
380
+ collaborative: sp_playlist.collaborative,
381
+ image_url: begin
382
+ sp_playlist&.images.first&.dig('url')
383
+ rescue StandardError => e
384
+ nil
385
+ end
386
+ )
387
+ )
388
+
389
+ new_playlist.id = self.from_sp_id(sp_playlist.id, new_playlist)
390
+
391
+ sp_author = sp_playlist&.owner
392
+ unless sp_author.nil?
393
+ new_author = self.from_sp_user(sp_author, new_playlist)
394
+ new_playlist.author_id = new_author.id
395
+ new_playlist.store_user(new_author)
396
+ end
397
+
398
+ sp_added_bys = sp_playlist.tracks_added_by
399
+ sp_added_ats = sp_playlist.tracks_added_at
400
+
401
+ sp_tracks = sp_tracks || self.all_sp_playlist_tracks(sp_playlist)
402
+ log.info "Got #{sp_tracks.length} playlist track(s) for '#{sp_playlist.name}'..."
403
+ sp_tracks.each do |sp_track|
404
+ new_track, new_artists, new_album = self.from_sp_track(sp_track, new_playlist)
405
+ new_track.added_at = sp_added_ats[sp_track.id]
406
+
407
+ sp_added_by = sp_added_bys[sp_track.id]
408
+ unless sp_added_by.nil?
409
+ new_added_by = self.from_sp_user(sp_added_by, new_playlist)
410
+ new_track.added_by = new_added_by.id
411
+ new_playlist.store_user(new_added_by)
412
+ end
413
+
414
+ new_artists.each do |new_artist|
415
+ new_playlist.store_artist(new_artist)
416
+ end
417
+
418
+ new_playlist.store_album(new_album)
419
+ new_playlist.store_track(new_track)
420
+ end
421
+
422
+ new_playlist
423
+ end
424
+
425
+ # Upload helpers
426
+
427
+ def to_sp_track(track, playlist)
428
+ sp_id = track&.spotify&.id
429
+ unless sp_id.nil?
430
+ # We already have an associated Spotify ID
431
+ RSpotify::Track.find(sp_id)
432
+ else
433
+ # We need to search for the song
434
+ search_phrase = playlist.track_search_phrase(track)
435
+ sp_results = RSpotify::Track.search(search_phrase, limit: 1)
436
+ sp_track = sp_results[0]
437
+
438
+ unless sp_track.nil?
439
+ log.info "Matched '#{track.name}' with '#{sp_track.name}' by '#{sp_track.artists.map { |a| a.name }.join(', ')}' from Spotify"
440
+ end
441
+
442
+ sp_track
443
+ end
444
+ end
445
+
446
+ def to_sp_tracks(tracks, playlist)
447
+ unless tracks.nil? || tracks.empty?
448
+ sp_tracks = tracks[...TO_SPOTIFY_TRACKS_CHUNK_SIZE].filter_map { |t| self.to_sp_track(t, playlist) }
449
+ sp_tracks + to_sp_tracks(tracks[TO_SPOTIFY_TRACKS_CHUNK_SIZE...], playlist)
450
+ else
451
+ []
452
+ end
453
+ end
454
+
455
+ def upload_sp_playlist_tracks(sp_tracks, sp_playlist)
456
+ unless sp_tracks.nil? || sp_tracks.empty?
457
+ sp_playlist.add_tracks!(sp_tracks[...UPLOAD_PLAYLIST_TRACKS_CHUNK_SIZE])
458
+ self.upload_sp_playlist_tracks(sp_tracks[UPLOAD_PLAYLIST_TRACKS_CHUNK_SIZE...], sp_playlist)
459
+ end
460
+ end
461
+
462
+ def upload_playlist(playlist)
463
+ sp_playlist = @me.create_playlist!(
464
+ playlist.name,
465
+ description: playlist.description,
466
+ # TODO: Use public/collaborative from playlist?
467
+ public: false,
468
+ collaborative: false
469
+ )
470
+
471
+ tracks = playlist.tracks
472
+
473
+ log.info "Externalizing #{tracks.length} playlist track(s)..."
474
+ sp_tracks = self.to_sp_tracks(tracks, playlist)
475
+
476
+ log.info "Uploading #{sp_tracks.length} playlist track(s)..."
477
+ self.upload_sp_playlist_tracks(sp_tracks, sp_playlist)
478
+
479
+ # TODO: Clone the original playlist and insert potentially new Spotify ids
480
+ nil
481
+ end
482
+
483
+ # Ref parsing
484
+
485
+ def parse_resource_type(raw)
486
+ case raw
487
+ when 'playlist' then :playlist
488
+ when 'album' then :album
489
+ when 'track' then :track
490
+ when 'user' then :user
491
+ when 'artist' then :artist
492
+ else nil
493
+ end
494
+ end
495
+
496
+ def parse_spotify_link(raw)
497
+ uri = URI(raw)
498
+ unless ['http', 'https'].include?(uri&.scheme) && uri&.host == 'open.spotify.com'
499
+ return nil
500
+ end
501
+
502
+ parsed_path = uri.path.split('/')
503
+ unless parsed_path.length == 3
504
+ return nil
505
+ end
506
+
507
+ resource_type = self.parse_resource_type(parsed_path[1])
508
+ resource_location = parsed_path[2]
509
+
510
+ Ref.new(self.name, resource_type, resource_location)
511
+ end
512
+
513
+ def parse_spotify_uri(raw)
514
+ uri = URI(raw)
515
+ unless uri&.scheme == 'spotify'
516
+ return nil
517
+ end
518
+
519
+ parsed_path = uri.opaque.split(':')
520
+ unless parsed_path.length == 2
521
+ return nil
522
+ end
523
+
524
+ resource_type = self.parse_resource_type(parsed_path[0])
525
+ resource_location = parsed_path[1]
526
+
527
+ Ref.new(self.name, resource_type, resource_location)
528
+ end
529
+
530
+ def parse_ref(raw_ref)
531
+ if raw_ref.is_token
532
+ location = case raw_ref.text
533
+ when "#{self.name}/tracks" then :tracks
534
+ when "#{self.name}/playlists" then :playlists
535
+ else return nil
536
+ end
537
+ Ref.new(self.name, :special, location)
538
+ else
539
+ self.parse_spotify_link(raw_ref.text) || self.parse_spotify_uri(raw_ref.text)
540
+ end
541
+ end
542
+
543
+ # Service
544
+
545
+ def download(ref)
546
+ self.authenticate
547
+
548
+ case ref.resource_type
549
+ when :special
550
+ case ref.resource_location
551
+ when :playlists
552
+ log.info 'Querying playlists...'
553
+ sp_playlists = self.all_sp_library_playlists
554
+
555
+ log.info 'Fetching playlists...'
556
+ Enumerator.new(sp_playlists.length) do |enum|
557
+ sp_playlists.each do |sp_playlist|
558
+ new_playlist = self.from_sp_playlist(sp_playlist)
559
+ enum.yield new_playlist
560
+ end
561
+ end
562
+ when :tracks
563
+ log.info 'Querying saved tracks...'
564
+ sp_saved_tracks = self.all_sp_library_tracks
565
+
566
+ log.info 'Fetching saved tracks...'
567
+ new_playlist = Playlist.new(
568
+ name: 'Saved Tracks'
569
+ )
570
+ new_me = self.from_sp_user(@me, new_playlist)
571
+ new_playlist.id = self.from_sp_id(new_me.id, new_playlist)
572
+ new_playlist.author_id = new_me.id
573
+ new_playlist.store_user(new_me)
574
+
575
+ sp_saved_tracks.each do |sp_track|
576
+ new_track, new_artists, new_album = self.from_sp_track(sp_track, new_playlist)
577
+
578
+ new_artists.each do |new_artist|
579
+ new_playlist.store_artist(new_artist)
580
+ end
581
+
582
+ new_playlist.store_album(new_album)
583
+ new_playlist.store_track(new_track)
584
+ end
585
+
586
+ [new_playlist]
587
+ else raise "Special resource location '#{ref.resource_location}' cannot be downloaded (yet)"
588
+ end
589
+ when :playlist
590
+ sp_playlist = RSpotify::Playlist.find_by_id(ref.resource_location)
591
+ new_playlist = self.from_sp_playlist(sp_playlist)
592
+
593
+ [new_playlist]
594
+ else raise "Resource type '#{ref.resource_type}' cannot be downloaded (yet)"
595
+ end
596
+ end
597
+
598
+ def upload(ref, playlists)
599
+ self.authenticate
600
+
601
+ # Note that pushes currently intentionally always create a new playlist
602
+ # TODO: Flag for overwriting (something like -f, --force?)
603
+ # (the flag should be declared in the CLI and perhaps added
604
+ # to Service.upload as a parameter)
605
+
606
+ unless ref.resource_type == :special && ref.resource_location == :playlists
607
+ raise 'Cannot upload to anything other than @spotify/playlists yet!'
608
+ end
609
+
610
+ playlists.each do |playlist|
611
+ self.upload_playlist(playlist)
612
+ end
613
+ end
614
+ end
615
+ end