libspotify.rb (14492B)
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://localhost: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` 140 end 141 142 def store_refresh_token(token) 143 system("security add-generic-password -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 157 auth 158 @base_url = URI("https://api.spotify.com/v1/") 159 @http = Net::HTTP.new(@base_url.host, @base_url.port) 160 @http.use_ssl = true 161 @http.verify_mode = OpenSSL::SSL::VERIFY_NONE 162 end 163 164 def api_call_get(endpoint, params = {}) 165 url = @base_url + endpoint 166 url.query = URI.encode_www_form(params) 167 url_call_get(url) 168 end 169 170 def api_call_post(endpoint, body) 171 url = @base_url + endpoint 172 url_call_post(url, body) 173 end 174 175 def api_call_put(endpoint, body, params = {}) 176 url = @base_url + endpoint 177 url.query = URI.encode_www_form(params) 178 url_call_put(url, body) 179 end 180 181 def url_call_get(url) 182 request = Net::HTTP::Get.new(url) 183 request["Authorization"] = "Bearer #{@token}" 184 begin 185 resp = @http.request(request) 186 rescue 187 puts("Connection broke, retrying request to #{url}") 188 sleep(2) 189 return url_call_get(url) 190 end 191 192 if resp.code_type == Net::HTTPTooManyRequests 193 wait_seconds = resp["Retry-After"].to_i 194 wait_min = wait_seconds / 60 195 if wait_min > 30 196 puts("Rate limited to wait more than half an hour (#{wait_min} min), exiting") 197 exit(1) 198 end 199 200 # Wait and retry 201 sleep(wait_seconds) 202 return url_call_get(url) 203 elsif resp.code_type != Net::HTTPOK 204 puts("Request #{url} returned #{resp}") 205 exit(1) 206 end 207 208 JSON.parse(resp.read_body) 209 end 210 211 def url_call_post(url, body) 212 request = Net::HTTP::Post.new(url) 213 request["Authorization"] = "Bearer #{@token}" 214 request.body = JSON.dump(body) 215 request.content_type = "application/json" 216 JSON.parse(@http.request(request).read_body) 217 end 218 219 def url_call_put(url, body) 220 request = Net::HTTP::Put.new(url) 221 request["Authorization"] = "Bearer #{@token}" 222 request.body = JSON.dump(body) 223 request.content_type = "application/json" 224 response_body = @http.request(request).read_body 225 response_body.nil? ? nil : JSON.parse(response_body) 226 end 227 228 def api_call_get_unpaginate(endpoint, params, results_key = nil) 229 res = api_call_get(endpoint, params) 230 return res if res.key?("error") 231 232 if results_key.nil? 233 data = res.items 234 url = res.next 235 236 until url.nil? 237 res = url_call_get(url) 238 data += res.items 239 url = res.next 240 end 241 else 242 data = res[results_key].items 243 url = res[results_key].next 244 245 until url.nil? 246 res = url_call_get(url) 247 data += res[results_key].items 248 url = res[results_key].next 249 end 250 end 251 252 data 253 end 254 255 def get_followed_artists 256 api_call_get_unpaginate("me/following", {type: :artist, limit: 50}, "artists") 257 end 258 259 def get_artists_releases(artists) 260 total = artists.size 261 print("Processing 0/#{total}") 262 releases = artists 263 .each 264 .with_index 265 .reduce([]) do |acc, (artist, i)| 266 print("\rProcessing #{i + 1}/#{total}") 267 response = api_call_get( 268 "artists/#{artist.id}/albums", 269 {limit: 50, include_groups: "album,single,appears_on"} 270 ) 271 albums = response.items 272 albums.each { |album| 273 album["release_date"] = album.release_date.split("-").size == 1 ? Date.iso8601("#{album.release_date}-01") : Date 274 .iso8601(album.release_date) 275 } 276 acc + albums 277 end 278 .reject { |album| album.album_type == "compilation" } 279 print("\n") 280 281 puts("Sorting") 282 releases.sort_by(&:release_date) 283 end 284 285 def add_to_playlist_if_not_present(playlist_id, tracks) 286 playlist_track_uris = api_call_get_unpaginate("playlists/#{playlist_id}/tracks", {limit: 50}).map { |x| 287 x.track.uri 288 } 289 track_uris = tracks.map { _1[:uri] } 290 to_add = track_uris.reject { |t| playlist_track_uris.include?(t) } 291 puts("Adding #{to_add.size} new tracks to playlist.") 292 to_add.each_slice(100) do |uris_slice| 293 body = {:"uris" => uris_slice} 294 api_call_post("playlists/#{playlist_id}/tracks", body) 295 end 296 end 297 end 298 299 def playlist_overview( 300 playlist_id 301 # to download: 302 # playlist_id = '52qgFnbZwV36ogaGUquBDt' 303 ) 304 client = SpotifyClient.new 305 playlist_tracks = client.api_call_get_unpaginate("playlists/#{playlist_id}/tracks", {limit: 50}) 306 by_artist = playlist_tracks.group_by { _1.track.artists.first.name } 307 by_artist_album = by_artist.reduce({}) { |h, (artist, tracks)| 308 h[artist] = tracks.group_by { |t| t.track.album.name } 309 h 310 } 311 res = by_artist_album.reduce({}) do |h, (artist, albums)| 312 h[artist] = albums.reduce({}) do |h2, (album, tracks)| 313 h2[album] = tracks.map { |track| track.track.name }.uniq 314 h2 315 end 316 317 h 318 end 319 320 puts(JSON.dump(res)) 321 end 322 323 # Process new releases since the date in ~/.local/share/spot-last-checked, add 324 # them to a tracks or albums playlist. 325 def process_new_releases(interactive = true) 326 tracks_playlist = "4agx19QeJFwPQRWeTViq9d" 327 albums_playlist = "2qYpNB8LDicKjcm5Px1dDQ" 328 329 client = SpotifyClient.new 330 artists = client.get_followed_artists 331 releases = client.get_artists_releases(artists) 332 last_checked = YAML.load_file("#{ENV["HOME"]}/.local/share/spot-last-checked", permitted_classes: [Date, Symbol]) 333 albums, others = releases.select { |r| r.release_date >= last_checked }.partition { |x| x.album_type == "album" } 334 335 albums_tracks = albums.reduce([]) do |acc, album| 336 album_tracks = client.api_call_get_unpaginate("albums/#{album.id}/tracks", {limit: 50}) 337 album_tracks.each { |track| track["album"] = album["name"] } 338 acc + album_tracks 339 end 340 341 others_tracks = others.reduce([]) do |acc, album| 342 album_tracks = client.api_call_get_unpaginate("albums/#{album.id}/tracks", {limit: 50}) 343 album_tracks.each { |track| track["album"] = album["name"] } 344 acc + album_tracks 345 end 346 347 if interactive 348 trackfile = Tempfile.create 349 trackfile_path = trackfile.path 350 albumfile = Tempfile.create 351 albumfile_path = albumfile.path 352 353 albums_tracks.each do |t| 354 albumfile.puts([t.artists.map(&:name).join(", "), t.name, t.album, t.uri].join("\t")) 355 end 356 357 others_tracks.each do |t| 358 trackfile.puts([t.artists.map(&:name).join(", "), t.name, t.album, t.uri].join("\t")) 359 end 360 361 trackfile.close 362 albumfile.close 363 364 system("nvim", "-o", albumfile_path, trackfile_path) 365 366 trackfile = File.open(trackfile_path, "r") 367 albumfile = File.open(albumfile_path, "r") 368 albums_tracks = albumfile.readlines.map { {uri: _1.chomp.split("\t").last} } 369 others_tracks = trackfile.readlines.map { {uri: _1.chomp.split("\t").last} } 370 371 trackfile.close 372 albumfile.close 373 File.unlink(trackfile.path) 374 File.unlink(albumfile.path) 375 end 376 377 puts("Processing tracks") 378 client.add_to_playlist_if_not_present(tracks_playlist, others_tracks) 379 puts("Processing albums") 380 client.add_to_playlist_if_not_present(albums_playlist, albums_tracks) 381 File.write("#{ENV["HOME"]}/.local/share/spot-last-checked", YAML.dump(Date.today)) 382 end 383 384 # Bulk follow artists from mpd, accessed using mpc. 385 # Asks you to edit a file with artist names to choose who to follow. 386 def bulk_follow_artists 387 require "tempfile" 388 389 client = SpotifyClient.new 390 puts("Getting followed artists...") 391 already_following = client.get_followed_artists 392 puts("Found #{already_following.size}") 393 394 puts("Getting artists from local library...") 395 all_lines = `mpc listall -f '%albumartist%'`.lines(chomp: true).uniq.reject(&:empty?) 396 puts("Found #{all_lines.size}") 397 puts("Looking up artists on spotify...") 398 artists = [] 399 total = all_lines.size 400 print("Processing 0/#{total}") 401 all_lines.each.with_index do |artist, i| 402 print("\rProcessing #{i + 1}/#{total}: #{artist}") 403 # TODO: in search, maybe look for an artist where I've already liked a song? 404 response = client.api_call_get("search", {q: artist, type: :artist}) 405 found_artists = response["artists"]["items"] 406 if found_artists.nil? 407 warn("No artist found for #{artist}") 408 next 409 end 410 411 found_artist = found_artists[0] 412 if found_artist.nil? 413 warn("No artist found for #{artist}") 414 else 415 found_artist["search_query"] = artist 416 artists << found_artist unless artists.include?(found_artist) 417 end 418 end 419 420 puts("Filtering already followed artists...") 421 artists_to_follow_ignore = if File.exist?("#{ENV["HOME"]}/.local/share/spot-artists-follow-ignore") 422 File.readlines("#{ENV["HOME"]}/.local/share/spot-artists-follow-ignore").map { 423 _1.chomp.split("\t") 424 } 425 else 426 [] 427 end 428 429 artists_to_follow_without_followed_obj = artists 430 .reject { |a| already_following.find { |af| af["uri"] == a["uri"] } } 431 artists_to_follow_without_followed_arr = artists_to_follow_without_followed_obj 432 .map { |a| [a["name"], a["external_urls"]["spotify"], a["search_query"]] } 433 artists_to_follow = artists_to_follow_without_followed_arr - artists_to_follow_ignore 434 435 tmpfile = Tempfile.new("artists_to_follow") 436 begin 437 tmpfile.write( 438 artists_to_follow.map { _1.join("\t") }.join("\n") 439 ) 440 tmpfile.close 441 system(ENV["EDITOR"], tmpfile.path) 442 tmpfile.open 443 new_artists_to_follow = tmpfile 444 .readlines(chomp: true) 445 .reduce([]) do |res, chosen| 446 name, href, _query = chosen.split("\t") 447 res << 448 artists_to_follow_without_followed_obj.find { |a| 449 a["name"] == name && a["external_urls"]["spotify"] == href 450 } 451 end 452 .reject(&:empty?) 453 454 ensure 455 tmpfile.close 456 tmpfile.unlink 457 end 458 459 to_subtract = new_artists_to_follow 460 .map { |a| [a["name"], a["external_urls"]["spotify"], a["search_query"]] } 461 462 to_add_to_ignore = (artists_to_follow - to_subtract) 463 puts("Adding #{to_add_to_ignore.size} artists to ignore file") 464 new_ignore = (artists_to_follow_ignore + to_add_to_ignore).uniq 465 File.write("#{ENV["HOME"]}/.local/share/spot-artists-follow-ignore", new_ignore.map { _1.join("\t") }.join("\n")) 466 467 new_artists_to_follow.each_slice(50) do |artists_by_50| 468 ids = artists_by_50.map { _1["id"] } 469 response = client.api_call_put("me/following", {ids: ids}, {type: :artist}) 470 puts(response) 471 end 472 end