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.
- checksums.yaml +7 -0
- data/.editorconfig +10 -0
- data/.github/workflows/deploy.yml +33 -0
- data/.github/workflows/documentation.yml +29 -0
- data/.github/workflows/test.yml +19 -0
- data/.gitignore +13 -0
- data/Gemfile +12 -0
- data/Gemfile.lock +134 -0
- data/LICENSE +21 -0
- data/README.md +114 -0
- data/Rakefile +13 -0
- data/artwork/icon.svg +186 -0
- data/artwork/icon128.png +0 -0
- data/bin/console +14 -0
- data/bin/drum +5 -0
- data/bin/drum.bat +2 -0
- data/bin/setup +8 -0
- data/drum.gemspec +36 -0
- data/lib/drum/model/album.rb +114 -0
- data/lib/drum/model/artist.rb +72 -0
- data/lib/drum/model/playlist.rb +222 -0
- data/lib/drum/model/raw_ref.rb +29 -0
- data/lib/drum/model/ref.rb +19 -0
- data/lib/drum/model/track.rb +157 -0
- data/lib/drum/model/user.rb +72 -0
- data/lib/drum/service/applemusic.rb +619 -0
- data/lib/drum/service/file.rb +89 -0
- data/lib/drum/service/mock.rb +50 -0
- data/lib/drum/service/service.rb +43 -0
- data/lib/drum/service/spotify.rb +615 -0
- data/lib/drum/service/stdio.rb +51 -0
- data/lib/drum/utils/ext.rb +88 -0
- data/lib/drum/utils/log.rb +93 -0
- data/lib/drum/utils/persist.rb +50 -0
- data/lib/drum/version.rb +3 -0
- data/lib/drum.rb +250 -0
- data/userdoc/playlist-format.md +96 -0
- metadata +207 -0
@@ -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
|