libspotify.rb (14884B)
1 #!/usr/bin/env ruby 2 require "uri" 3 require "net/http" 4 require "openssl" 5 require "json" 6 require "date" 7 require "yaml" 8 require "webrick" 9 10 # Add easy access to hash members for JSON stuff 11 class Hash 12 def method_missing(meth, *_args, &_block) 13 raise NoMethodError unless key?(meth.to_s) 14 15 self[meth.to_s] 16 end 17 end 18 19 require "securerandom" 20 require "digest" 21 require "base64" 22 23 # Client to access Spotify 24 class SpotifyClient 25 CLIENT_ID = "c747e580651248da8e1035c88b3d2065" 26 REDIRECT_URI = "http://127.0.0.1:4815/callback" 27 28 # OAUTH functions 29 def self.generate_random_string(length) 30 possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" 31 values = SecureRandom.random_bytes(length).bytes 32 values.reduce("") { |acc, x| acc + possible[x % possible.length] } 33 end 34 35 def auth_refresh_token(refresh_token) 36 url = URI("https://accounts.spotify.com/api/token") 37 body = { 38 grant_type: :refresh_token, 39 refresh_token: refresh_token, 40 client_id: CLIENT_ID 41 } 42 request = Net::HTTP::Post.new(url) 43 request.body = URI.encode_www_form(body) 44 request.content_type = "application/x-www-form-urlencoded" 45 http = Net::HTTP::new(url.host, url.port) 46 http.use_ssl = true 47 http.verify_mode = OpenSSL::SSL::VERIFY_NONE 48 response = http.request(request) 49 resp = JSON.parse(response.read_body) 50 store_refresh_token(resp["refresh_token"]) 51 return resp["access_token"] 52 end 53 54 def auth_obtain_code(state, code_challenge) 55 scope = %w[ 56 user-read-private 57 playlist-read-collaborative 58 playlist-modify-public 59 playlist-modify-private 60 streaming 61 ugc-image-upload 62 user-follow-modify 63 user-follow-read 64 user-library-read 65 user-library-modify 66 user-read-private 67 user-read-email 68 user-top-read 69 user-read-playback-state 70 user-modify-playback-state 71 user-read-currently-playing 72 user-read-recently-played 73 ] 74 .join(" ") 75 params = { 76 client_id: CLIENT_ID, 77 response_type: :code, 78 scope: scope, 79 show_dialog: false, 80 redirect_uri: REDIRECT_URI, 81 state: state, 82 code_challenge_method: :S256, 83 code_challenge: code_challenge 84 } 85 url = URI("https://accounts.spotify.com/authorize") 86 url.query = URI.encode_www_form(params) 87 88 server = WEBrick::HTTPServer.new( 89 Port: 4815, 90 Logger: WEBrick::Log.new("/dev/null"), 91 AccessLog: [] 92 ) 93 server.mount_proc("/callback") do |req, res| 94 res.status = 200 95 abort("Mismatched state") if req.query["state"] != state 96 @auth_code = req.query["code"] 97 server.stop 98 end 99 100 t = Thread.new { server.start } 101 puts("If it doesn't open automatically, open this in your browser:\n#{url}") 102 system("open", url) 103 t.join 104 end 105 106 def auth_request_token(state, code_verifier) 107 url = URI("https://accounts.spotify.com/api/token") 108 body = { 109 grant_type: :authorization_code, 110 code: @auth_code, 111 redirect_uri: REDIRECT_URI, 112 client_id: CLIENT_ID, 113 code_verifier: code_verifier 114 } 115 request = Net::HTTP::Post.new(url) 116 request.body = URI.encode_www_form(body) 117 request.content_type = "application/x-www-form-urlencoded" 118 http = Net::HTTP::new(url.host, url.port) 119 http.use_ssl = true 120 http.verify_mode = OpenSSL::SSL::VERIFY_NONE 121 response = http.request(request) 122 resp = JSON.parse(response.read_body) 123 store_refresh_token(resp["refresh_token"]) 124 return resp["access_token"] 125 end 126 127 # Carry out OAUTH authorization with PKCE flow 128 def auth_obtain_token 129 code_verifier = SpotifyClient.generate_random_string(64) 130 code_challenge = Base64.strict_encode64(Digest::SHA256.digest(code_verifier)).tr("+/", "-_").gsub("=", "") 131 132 state = Base64.strict_encode64(Digest::SHA256.digest(SpotifyClient.generate_random_string(64))) 133 auth_obtain_code(state, code_challenge) 134 abort("No auth code") if @auth_code.nil? 135 return auth_request_token(state, code_verifier) 136 end 137 138 def get_refresh_token 139 `security find-generic-password -a spotify -s spotify_refresh_token -w`.strip 140 end 141 142 def store_refresh_token(token) 143 system("security add-generic-password -U -a spotify -s spotify_refresh_token -w \"#{token}\"") 144 end 145 146 def auth 147 refresh_token = get_refresh_token 148 149 if refresh_token.empty? 150 @token = auth_obtain_token 151 else 152 @token = auth_refresh_token(refresh_token) 153 end 154 end 155 156 def initialize_http 157 @http = Net::HTTP.new(@base_url.host, @base_url.port) 158 @http.use_ssl = true 159 @http.verify_mode = OpenSSL::SSL::VERIFY_NONE 160 end 161 162 def initialize 163 auth 164 @base_url = URI("https://api.spotify.com/v1/") 165 initialize_http 166 end 167 168 def api_call_get(endpoint, params = {}) 169 url = @base_url + endpoint 170 url.query = URI.encode_www_form(params) 171 url_call_get(url) 172 end 173 174 def api_call_post(endpoint, body) 175 url = @base_url + endpoint 176 url_call_post(url, body) 177 end 178 179 def api_call_put(endpoint, body, params = {}) 180 url = @base_url + endpoint 181 url.query = URI.encode_www_form(params) 182 url_call_put(url, body) 183 end 184 185 def url_call_get(url) 186 request = Net::HTTP::Get.new(url) 187 request["Authorization"] = "Bearer #{@token}" 188 begin 189 resp = @http.request(request) 190 rescue Exception => e 191 puts("Connection broke (#{e}), retrying request to #{url}") 192 binding.irb if File.exist?("/tmp/rubydebug") 193 194 sleep(2) 195 initialize_http 196 return url_call_get(url) 197 end 198 199 if resp.code_type == Net::HTTPTooManyRequests 200 wait_seconds = resp["Retry-After"].to_i 201 wait_min = wait_seconds / 60 202 if wait_min > 30 203 puts("Rate limited to wait more than half an hour (#{wait_min} min), exiting") 204 exit(1) 205 end 206 207 # Wait and retry 208 sleep(wait_seconds) 209 return url_call_get(url) 210 elsif resp.code_type != Net::HTTPOK 211 puts("Request #{url} returned #{resp}") 212 exit(1) 213 end 214 215 JSON.parse(resp.read_body) 216 end 217 218 def url_call_post(url, body) 219 request = Net::HTTP::Post.new(url) 220 request["Authorization"] = "Bearer #{@token}" 221 request.body = JSON.dump(body) 222 request.content_type = "application/json" 223 begin 224 JSON.parse(@http.request(request).read_body) 225 rescue Exception => e 226 puts("Connection broke (#{e}), retrying request to #{url}") 227 binding.irb if File.exist?("/tmp/rubydebug") 228 sleep(2) 229 initialize_http 230 return url_call_post(url, body) 231 end 232 end 233 234 def url_call_put(url, body) 235 request = Net::HTTP::Put.new(url) 236 request["Authorization"] = "Bearer #{@token}" 237 request.body = JSON.dump(body) 238 request.content_type = "application/json" 239 response_body = @http.request(request).read_body 240 response_body.nil? ? nil : JSON.parse(response_body) 241 end 242 243 def api_call_get_unpaginate(endpoint, params, results_key = nil) 244 res = api_call_get(endpoint, params) 245 return res if res.key?("error") 246 247 if results_key.nil? 248 data = res.items 249 url = res.next 250 251 until url.nil? 252 res = url_call_get(url) 253 data += res.items 254 url = res.next 255 end 256 else 257 data = res[results_key].items 258 url = res[results_key].next 259 260 until url.nil? 261 res = url_call_get(url) 262 data += res[results_key].items 263 url = res[results_key].next 264 end 265 end 266 267 data 268 end 269 270 def get_followed_artists 271 api_call_get_unpaginate("me/following", {type: :artist, limit: 50}, "artists") 272 end 273 274 def get_artists_releases(artists) 275 total = artists.size 276 print("Processing 0/#{total}") 277 releases = artists 278 .each 279 .with_index 280 .reduce([]) do |acc, (artist, i)| 281 print("\rProcessing #{i + 1}/#{total}") 282 response = api_call_get( 283 "artists/#{artist.id}/albums", 284 {limit: 50, include_groups: "album,single,appears_on"} 285 ) 286 albums = response.items 287 albums.each { |album| 288 album["release_date"] = album.release_date.split("-").size == 1 ? Date.iso8601("#{album.release_date}-01") : Date 289 .iso8601(album.release_date) 290 } 291 acc + albums 292 end 293 .reject { |album| album.album_type == "compilation" } 294 print("\n") 295 296 puts("Sorting") 297 releases.sort_by(&:release_date) 298 end 299 300 def add_to_playlist_if_not_present(playlist_id, tracks) 301 playlist_track_uris = api_call_get_unpaginate("playlists/#{playlist_id}/tracks", {limit: 50}).map { |x| 302 x.track.uri 303 } 304 track_uris = tracks.map { _1[:uri] } 305 to_add = track_uris.reject { |t| playlist_track_uris.include?(t) } 306 puts("Adding #{to_add.size} new tracks to playlist.") 307 to_add.each_slice(100) do |uris_slice| 308 body = {:"uris" => uris_slice} 309 api_call_post("playlists/#{playlist_id}/tracks", body) 310 end 311 end 312 end 313 314 def playlist_overview( 315 playlist_id 316 # to download: 317 # playlist_id = '52qgFnbZwV36ogaGUquBDt' 318 ) 319 client = SpotifyClient.new 320 playlist_tracks = client.api_call_get_unpaginate("playlists/#{playlist_id}/tracks", {limit: 50}) 321 by_artist = playlist_tracks.group_by { _1.track.artists.first.name } 322 by_artist_album = by_artist.reduce({}) { |h, (artist, tracks)| 323 h[artist] = tracks.group_by { |t| t.track.album.name } 324 h 325 } 326 res = by_artist_album.reduce({}) do |h, (artist, albums)| 327 h[artist] = albums.reduce({}) do |h2, (album, tracks)| 328 h2[album] = tracks.map { |track| track.track.name }.uniq 329 h2 330 end 331 332 h 333 end 334 335 puts(JSON.dump(res)) 336 end 337 338 # Process new releases since the date in ~/.local/share/spot-last-checked, add 339 # them to a tracks or albums playlist. 340 def process_new_releases(interactive = true) 341 tracks_playlist = "4agx19QeJFwPQRWeTViq9d" 342 albums_playlist = "2qYpNB8LDicKjcm5Px1dDQ" 343 344 client = SpotifyClient.new 345 artists = client.get_followed_artists 346 releases = client.get_artists_releases(artists) 347 last_checked = YAML.load_file("#{ENV["HOME"]}/.local/share/spot-last-checked", permitted_classes: [Date, Symbol]) 348 albums, others = releases.select { |r| r.release_date >= last_checked }.partition { |x| x.album_type == "album" } 349 350 albums_tracks = albums.reduce([]) do |acc, album| 351 album_tracks = client.api_call_get_unpaginate("albums/#{album.id}/tracks", {limit: 50}) 352 album_tracks.each { |track| track["album"] = album["name"] } 353 acc + album_tracks 354 end 355 356 others_tracks = others.reduce([]) do |acc, album| 357 album_tracks = client.api_call_get_unpaginate("albums/#{album.id}/tracks", {limit: 50}) 358 album_tracks.each { |track| track["album"] = album["name"] } 359 acc + album_tracks 360 end 361 362 if interactive 363 trackfile = Tempfile.create 364 trackfile_path = trackfile.path 365 albumfile = Tempfile.create 366 albumfile_path = albumfile.path 367 368 albums_tracks.each do |t| 369 albumfile.puts([t.artists.map(&:name).join(", "), t.name, t.album, t.uri].join("\t")) 370 end 371 372 others_tracks.each do |t| 373 trackfile.puts([t.artists.map(&:name).join(", "), t.name, t.album, t.uri].join("\t")) 374 end 375 376 trackfile.close 377 albumfile.close 378 379 system("nvim", "-o", albumfile_path, trackfile_path) 380 381 trackfile = File.open(trackfile_path, "r") 382 albumfile = File.open(albumfile_path, "r") 383 albums_tracks = albumfile.readlines.map { {uri: _1.chomp.split("\t").last} } 384 others_tracks = trackfile.readlines.map { {uri: _1.chomp.split("\t").last} } 385 386 trackfile.close 387 albumfile.close 388 File.unlink(trackfile.path) 389 File.unlink(albumfile.path) 390 end 391 392 puts("Processing tracks") 393 client.add_to_playlist_if_not_present(tracks_playlist, others_tracks) 394 puts("Processing albums") 395 client.add_to_playlist_if_not_present(albums_playlist, albums_tracks) 396 File.write("#{ENV["HOME"]}/.local/share/spot-last-checked", YAML.dump(Date.today)) 397 end 398 399 # Bulk follow artists from mpd, accessed using mpc. 400 # Asks you to edit a file with artist names to choose who to follow. 401 def bulk_follow_artists 402 require "tempfile" 403 404 client = SpotifyClient.new 405 puts("Getting followed artists...") 406 already_following = client.get_followed_artists 407 puts("Found #{already_following.size}") 408 409 puts("Getting artists from local library...") 410 all_lines = `mpc listall -f '%albumartist%'`.lines(chomp: true).uniq.reject(&:empty?) 411 puts("Found #{all_lines.size}") 412 puts("Looking up artists on spotify...") 413 artists = [] 414 total = all_lines.size 415 print("Processing 0/#{total}") 416 all_lines.each.with_index do |artist, i| 417 print("\rProcessing #{i + 1}/#{total}: #{artist}") 418 # TODO: in search, maybe look for an artist where I've already liked a song? 419 response = client.api_call_get("search", {q: artist, type: :artist}) 420 found_artists = response["artists"]["items"] 421 if found_artists.nil? 422 warn("No artist found for #{artist}") 423 next 424 end 425 426 found_artist = found_artists[0] 427 if found_artist.nil? 428 warn("No artist found for #{artist}") 429 else 430 found_artist["search_query"] = artist 431 artists << found_artist unless artists.include?(found_artist) 432 end 433 end 434 435 puts("Filtering already followed artists...") 436 artists_to_follow_ignore = if File.exist?("#{ENV["HOME"]}/.local/share/spot-artists-follow-ignore") 437 File.readlines("#{ENV["HOME"]}/.local/share/spot-artists-follow-ignore").map { 438 _1.chomp.split("\t") 439 } 440 else 441 [] 442 end 443 444 artists_to_follow_without_followed_obj = artists 445 .reject { |a| already_following.find { |af| af["uri"] == a["uri"] } } 446 artists_to_follow_without_followed_arr = artists_to_follow_without_followed_obj 447 .map { |a| [a["name"], a["external_urls"]["spotify"], a["search_query"]] } 448 artists_to_follow = artists_to_follow_without_followed_arr - artists_to_follow_ignore 449 450 tmpfile = Tempfile.new("artists_to_follow") 451 begin 452 tmpfile.write( 453 artists_to_follow.map { _1.join("\t") }.join("\n") 454 ) 455 tmpfile.close 456 system(ENV["EDITOR"], tmpfile.path) 457 tmpfile.open 458 new_artists_to_follow = tmpfile 459 .readlines(chomp: true) 460 .reduce([]) do |res, chosen| 461 name, href, _query = chosen.split("\t") 462 res << 463 artists_to_follow_without_followed_obj.find { |a| 464 a["name"] == name && a["external_urls"]["spotify"] == href 465 } 466 end 467 .reject(&:empty?) 468 469 ensure 470 tmpfile.close 471 tmpfile.unlink 472 end 473 474 to_subtract = new_artists_to_follow 475 .map { |a| [a["name"], a["external_urls"]["spotify"], a["search_query"]] } 476 477 to_add_to_ignore = (artists_to_follow - to_subtract) 478 puts("Adding #{to_add_to_ignore.size} artists to ignore file") 479 new_ignore = (artists_to_follow_ignore + to_add_to_ignore).uniq 480 File.write("#{ENV["HOME"]}/.local/share/spot-artists-follow-ignore", new_ignore.map { _1.join("\t") }.join("\n")) 481 482 new_artists_to_follow.each_slice(50) do |artists_by_50| 483 ids = artists_by_50.map { _1["id"] } 484 response = client.api_call_put("me/following", {ids: ids}, {type: :artist}) 485 puts(response) 486 end 487 end