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