libspotify.rb (11906B)
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 # Client to access Spotify 20 class SpotifyClient 21 def set_token 22 client_id = "c747e580651248da8e1035c88b3d2065" 23 scope = %w[ 24 user-read-private 25 playlist-read-collaborative 26 playlist-modify-public 27 playlist-modify-private 28 streaming 29 ugc-image-upload 30 user-follow-modify 31 user-follow-read 32 user-library-read 33 user-library-modify 34 user-read-private 35 user-read-email 36 user-top-read 37 user-read-playback-state 38 user-modify-playback-state 39 user-read-currently-playing 40 user-read-recently-played 41 ] 42 .join("%20") 43 redirect_uri = "http://localhost:4815/callback" 44 url = "https://accounts.spotify.com/authorize?client_id=#{client_id}&response_type=token&scope=#{scope}&show_dialog=false&redirect_uri=#{redirect_uri}" 45 server = WEBrick::HTTPServer.new( 46 Port: 4815, 47 Logger: WEBrick::Log.new("/dev/null"), 48 AccessLog: [] 49 ) 50 server.mount_proc("/callback") do |_req, res| 51 res.body = <<-HTML 52 <!DOCTYPE html> 53 <html><body><script> 54 const hash = window.location.hash.substring(1); 55 fetch('/token?'+hash).then(() => {window.close()}) 56 </script></body></html> 57 HTML 58 end 59 60 server.mount_proc("/token") do |req, res| 61 res.status = 200 62 @token = req.query["access_token"] 63 server.stop 64 end 65 66 t = Thread.new { server.start } 67 puts("If it doesn't open automatically, open this in your browser:\n#{url}") 68 system("open", url) 69 t.join 70 end 71 72 def initialize 73 set_token 74 @base_url = URI("https://api.spotify.com/v1/") 75 @http = Net::HTTP.new(@base_url.host, @base_url.port) 76 @http.use_ssl = true 77 @http.verify_mode = OpenSSL::SSL::VERIFY_NONE 78 end 79 80 def api_call_get(endpoint, params = {}) 81 url = @base_url + endpoint 82 url.query = URI.encode_www_form(params) 83 url_call_get(url) 84 end 85 86 def api_call_post(endpoint, body) 87 url = @base_url + endpoint 88 url_call_post(url, body) 89 end 90 91 def api_call_put(endpoint, body, params = {}) 92 url = @base_url + endpoint 93 url.query = URI.encode_www_form(params) 94 url_call_put(url, body) 95 end 96 97 def url_call_get(url) 98 request = Net::HTTP::Get.new(url) 99 request["Authorization"] = "Bearer #{@token}" 100 begin 101 resp = @http.request(request) 102 rescue 103 puts("Connection broke, retrying request to #{url}") 104 sleep(2) 105 return url_call_get(url) 106 end 107 108 if resp.code_type == Net::HTTPTooManyRequests 109 wait_seconds = resp["Retry-After"].to_i 110 wait_min = wait_seconds / 60 111 if wait_min > 30 112 puts("Rate limited to wait more than half an hour (#{wait_min} min), exiting") 113 exit(1) 114 end 115 116 # Wait and retry 117 sleep(wait_seconds) 118 return url_call_get(url) 119 elsif resp.code_type != Net::HTTPOK 120 puts("Request #{url} returned #{resp}") 121 exit(1) 122 end 123 124 JSON.parse(resp.read_body) 125 end 126 127 def url_call_post(url, body) 128 request = Net::HTTP::Post.new(url) 129 request["Authorization"] = "Bearer #{@token}" 130 request.body = JSON.dump(body) 131 request.content_type = "application/json" 132 JSON.parse(@http.request(request).read_body) 133 end 134 135 def url_call_put(url, body) 136 request = Net::HTTP::Put.new(url) 137 request["Authorization"] = "Bearer #{@token}" 138 request.body = JSON.dump(body) 139 request.content_type = "application/json" 140 response_body = @http.request(request).read_body 141 response_body.nil? ? nil : JSON.parse(response_body) 142 end 143 144 def api_call_get_unpaginate(endpoint, params, results_key = nil) 145 res = api_call_get(endpoint, params) 146 return res if res.key?("error") 147 148 if results_key.nil? 149 data = res.items 150 url = res.next 151 152 until url.nil? 153 res = url_call_get(url) 154 data += res.items 155 url = res.next 156 end 157 else 158 data = res[results_key].items 159 url = res[results_key].next 160 161 until url.nil? 162 res = url_call_get(url) 163 data += res[results_key].items 164 url = res[results_key].next 165 end 166 end 167 168 data 169 end 170 171 def get_followed_artists 172 api_call_get_unpaginate("me/following", {type: :artist, limit: 50}, "artists") 173 end 174 175 def get_artists_releases(artists) 176 total = artists.size 177 print("Processing 0/#{total}") 178 releases = artists 179 .each 180 .with_index 181 .reduce([]) do |acc, (artist, i)| 182 print("\rProcessing #{i + 1}/#{total}") 183 response = api_call_get( 184 "artists/#{artist.id}/albums", 185 {limit: 50, include_groups: "album,single,appears_on"} 186 ) 187 albums = response.items 188 albums.each { |album| 189 album["release_date"] = album.release_date.split("-").size == 1 ? Date.iso8601("#{album.release_date}-01") : Date 190 .iso8601(album.release_date) 191 } 192 acc + albums 193 end 194 .reject { |album| album.album_type == "compilation" } 195 print("\n") 196 197 puts("Sorting") 198 releases.sort_by(&:release_date) 199 end 200 201 def add_to_playlist_if_not_present(playlist_id, tracks) 202 playlist_track_uris = api_call_get_unpaginate("playlists/#{playlist_id}/tracks", {limit: 50}).map { |x| 203 x.track.uri 204 } 205 track_uris = tracks.map { _1[:uri] } 206 to_add = track_uris.reject { |t| playlist_track_uris.include?(t) } 207 puts("Adding #{to_add.size} new tracks to playlist.") 208 to_add.each_slice(100) do |uris_slice| 209 body = {:"uris" => uris_slice} 210 api_call_post("playlists/#{playlist_id}/tracks", body) 211 end 212 end 213 end 214 215 def playlist_overview( 216 playlist_id 217 # to download: 218 # playlist_id = '52qgFnbZwV36ogaGUquBDt' 219 ) 220 client = SpotifyClient.new 221 playlist_tracks = client.api_call_get_unpaginate("playlists/#{playlist_id}/tracks", {limit: 50}) 222 by_artist = playlist_tracks.group_by { _1.track.artists.first.name } 223 by_artist_album = by_artist.reduce({}) { |h, (artist, tracks)| 224 h[artist] = tracks.group_by { |t| t.track.album.name } 225 h 226 } 227 res = by_artist_album.reduce({}) do |h, (artist, albums)| 228 h[artist] = albums.reduce({}) do |h2, (album, tracks)| 229 h2[album] = tracks.map { |track| track.track.name }.uniq 230 h2 231 end 232 233 h 234 end 235 236 puts(JSON.dump(res)) 237 end 238 239 # Process new releases since the date in ~/.local/share/spot-last-checked, add 240 # them to a tracks or albums playlist. 241 def process_new_releases(interactive = true) 242 tracks_playlist = "4agx19QeJFwPQRWeTViq9d" 243 albums_playlist = "2qYpNB8LDicKjcm5Px1dDQ" 244 245 client = SpotifyClient.new 246 artists = client.get_followed_artists 247 releases = client.get_artists_releases(artists) 248 last_checked = YAML.load_file("#{ENV["HOME"]}/.local/share/spot-last-checked", permitted_classes: [Date, Symbol]) 249 albums, others = releases.select { |r| r.release_date >= last_checked }.partition { |x| x.album_type == "album" } 250 251 albums_tracks = albums.reduce([]) do |acc, album| 252 album_tracks = client.api_call_get_unpaginate("albums/#{album.id}/tracks", {limit: 50}) 253 album_tracks.each { |track| track["album"] = album["name"] } 254 acc + album_tracks 255 end 256 257 others_tracks = others.reduce([]) do |acc, album| 258 album_tracks = client.api_call_get_unpaginate("albums/#{album.id}/tracks", {limit: 50}) 259 album_tracks.each { |track| track["album"] = album["name"] } 260 acc + album_tracks 261 end 262 263 if interactive 264 trackfile = Tempfile.create 265 trackfile_path = trackfile.path 266 albumfile = Tempfile.create 267 albumfile_path = albumfile.path 268 269 albums_tracks.each do |t| 270 albumfile.puts([t.artists.map(&:name).join(", "), t.name, t.album, t.uri].join("\t")) 271 end 272 273 others_tracks.each do |t| 274 trackfile.puts([t.artists.map(&:name).join(", "), t.name, t.album, t.uri].join("\t")) 275 end 276 277 trackfile.close 278 albumfile.close 279 280 system("nvim", "-o", albumfile_path, trackfile_path) 281 282 trackfile = File.open(trackfile_path, "r") 283 albumfile = File.open(albumfile_path, "r") 284 albums_tracks = albumfile.readlines.map { {uri: _1.chomp.split("\t").last} } 285 others_tracks = trackfile.readlines.map { {uri: _1.chomp.split("\t").last} } 286 287 trackfile.close 288 albumfile.close 289 File.unlink(trackfile.path) 290 File.unlink(albumfile.path) 291 end 292 293 puts("Processing tracks") 294 client.add_to_playlist_if_not_present(tracks_playlist, others_tracks) 295 puts("Processing albums") 296 client.add_to_playlist_if_not_present(albums_playlist, albums_tracks) 297 File.write("#{ENV["HOME"]}/.local/share/spot-last-checked", YAML.dump(Date.today)) 298 end 299 300 # Bulk follow artists from mpd, accessed using mpc. 301 # Asks you to edit a file with artist names to choose who to follow. 302 def bulk_follow_artists 303 require "tempfile" 304 305 client = SpotifyClient.new 306 puts("Getting followed artists...") 307 already_following = client.get_followed_artists 308 puts("Found #{already_following.size}") 309 310 puts("Getting artists from local library...") 311 all_lines = `mpc listall -f '%albumartist%'`.lines(chomp: true).uniq.reject(&:empty?) 312 puts("Found #{all_lines.size}") 313 puts("Looking up artists on spotify...") 314 artists = [] 315 total = all_lines.size 316 print("Processing 0/#{total}") 317 all_lines.each.with_index do |artist, i| 318 print("\rProcessing #{i + 1}/#{total}: #{artist}") 319 # TODO: in search, maybe look for an artist where I've already liked a song? 320 response = client.api_call_get("search", {q: artist, type: :artist}) 321 found_artists = response["artists"]["items"] 322 if found_artists.nil? 323 warn("No artist found for #{artist}") 324 next 325 end 326 327 found_artist = found_artists[0] 328 if found_artist.nil? 329 warn("No artist found for #{artist}") 330 else 331 found_artist["search_query"] = artist 332 artists << found_artist unless artists.include?(found_artist) 333 end 334 end 335 336 puts("Filtering already followed artists...") 337 artists_to_follow_ignore = if File.exist?("#{ENV["HOME"]}/.local/share/spot-artists-follow-ignore") 338 File.readlines("#{ENV["HOME"]}/.local/share/spot-artists-follow-ignore").map { 339 _1.chomp.split("\t") 340 } 341 else 342 [] 343 end 344 345 artists_to_follow_without_followed_obj = artists 346 .reject { |a| already_following.find { |af| af["uri"] == a["uri"] } } 347 artists_to_follow_without_followed_arr = artists_to_follow_without_followed_obj 348 .map { |a| [a["name"], a["external_urls"]["spotify"], a["search_query"]] } 349 artists_to_follow = artists_to_follow_without_followed_arr - artists_to_follow_ignore 350 351 tmpfile = Tempfile.new("artists_to_follow") 352 begin 353 tmpfile.write( 354 artists_to_follow.map { _1.join("\t") }.join("\n") 355 ) 356 tmpfile.close 357 system(ENV["EDITOR"], tmpfile.path) 358 tmpfile.open 359 new_artists_to_follow = tmpfile 360 .readlines(chomp: true) 361 .reduce([]) do |res, chosen| 362 name, href, _query = chosen.split("\t") 363 res << 364 artists_to_follow_without_followed_obj.find { |a| 365 a["name"] == name && a["external_urls"]["spotify"] == href 366 } 367 end 368 .reject(&:empty?) 369 370 ensure 371 tmpfile.close 372 tmpfile.unlink 373 end 374 375 to_subtract = new_artists_to_follow 376 .map { |a| [a["name"], a["external_urls"]["spotify"], a["search_query"]] } 377 378 to_add_to_ignore = (artists_to_follow - to_subtract) 379 puts("Adding #{to_add_to_ignore.size} artists to ignore file") 380 new_ignore = (artists_to_follow_ignore + to_add_to_ignore).uniq 381 File.write("#{ENV["HOME"]}/.local/share/spot-artists-follow-ignore", new_ignore.map { _1.join("\t") }.join("\n")) 382 383 new_artists_to_follow.each_slice(50) do |artists_by_50| 384 ids = artists_by_50.map { _1["id"] } 385 response = client.api_call_put("me/following", {ids: ids}, {type: :artist}) 386 puts(response) 387 end 388 end