libspotify.rb (11769B)
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 resp = @http.request(request) 101 if resp.code_type == Net::HTTPTooManyRequests 102 wait_seconds = resp["Retry-After"].to_i 103 wait_min = wait_seconds / 60 104 if wait_min > 30 105 puts("Rate limited to wait more than half an hour (#{wait_min} min), exiting") 106 exit(1) 107 end 108 109 # Wait and retry 110 sleep(wait_seconds) 111 return url_call_get(url) 112 elsif resp.code_type != Net::HTTPOK 113 puts("Request #{url} returned #{resp}") 114 exit(1) 115 end 116 117 JSON.parse(resp.read_body) 118 end 119 120 def url_call_post(url, body) 121 request = Net::HTTP::Post.new(url) 122 request["Authorization"] = "Bearer #{@token}" 123 request.body = JSON.dump(body) 124 request.content_type = "application/json" 125 JSON.parse(@http.request(request).read_body) 126 end 127 128 def url_call_put(url, body) 129 request = Net::HTTP::Put.new(url) 130 request["Authorization"] = "Bearer #{@token}" 131 request.body = JSON.dump(body) 132 request.content_type = "application/json" 133 response_body = @http.request(request).read_body 134 response_body.nil? ? nil : JSON.parse(response_body) 135 end 136 137 def api_call_get_unpaginate(endpoint, params, results_key = nil) 138 res = api_call_get(endpoint, params) 139 return res if res.key?("error") 140 141 if results_key.nil? 142 data = res.items 143 url = res.next 144 145 until url.nil? 146 res = url_call_get(url) 147 data += res.items 148 url = res.next 149 end 150 else 151 data = res[results_key].items 152 url = res[results_key].next 153 154 until url.nil? 155 res = url_call_get(url) 156 data += res[results_key].items 157 url = res[results_key].next 158 end 159 end 160 161 data 162 end 163 164 def get_followed_artists 165 api_call_get_unpaginate("me/following", {type: :artist, limit: 50}, "artists") 166 end 167 168 def get_artists_releases(artists) 169 total = artists.size 170 print("Processing 0/#{total}") 171 releases = artists 172 .each 173 .with_index 174 .reduce([]) do |acc, (artist, i)| 175 print("\rProcessing #{i + 1}/#{total}") 176 response = api_call_get( 177 "artists/#{artist.id}/albums", 178 {limit: 50, include_groups: "album,single,appears_on"} 179 ) 180 albums = response.items 181 albums.each { |album| 182 album["release_date"] = album.release_date.split("-").size == 1 ? Date.iso8601("#{album.release_date}-01") : Date 183 .iso8601(album.release_date) 184 } 185 acc + albums 186 end 187 .reject { |album| album.album_type == "compilation" } 188 print("\n") 189 190 puts("Sorting") 191 releases.sort_by(&:release_date) 192 end 193 194 def add_to_playlist_if_not_present(playlist_id, tracks) 195 playlist_track_uris = api_call_get_unpaginate("playlists/#{playlist_id}/tracks", {limit: 50}).map { |x| 196 x.track.uri 197 } 198 track_uris = tracks.map { _1[:uri] } 199 to_add = track_uris.reject { |t| playlist_track_uris.include?(t) } 200 puts("Adding #{to_add.size} new tracks to playlist.") 201 to_add.each_slice(100) do |uris_slice| 202 body = {:"uris" => uris_slice} 203 api_call_post("playlists/#{playlist_id}/tracks", body) 204 end 205 end 206 end 207 208 def playlist_overview( 209 playlist_id 210 # to download: 211 # playlist_id = '52qgFnbZwV36ogaGUquBDt' 212 ) 213 client = SpotifyClient.new 214 playlist_tracks = client.api_call_get_unpaginate("playlists/#{playlist_id}/tracks", {limit: 50}) 215 by_artist = playlist_tracks.group_by { _1.track.artists.first.name } 216 by_artist_album = by_artist.reduce({}) { |h, (artist, tracks)| 217 h[artist] = tracks.group_by { |t| t.track.album.name } 218 h 219 } 220 res = by_artist_album.reduce({}) do |h, (artist, albums)| 221 h[artist] = albums.reduce({}) do |h2, (album, tracks)| 222 h2[album] = tracks.map { |track| track.track.name }.uniq 223 h2 224 end 225 226 h 227 end 228 229 puts(JSON.dump(res)) 230 end 231 232 # Process new releases since the date in ~/.local/share/spot-last-checked, add 233 # them to a tracks or albums playlist. 234 def process_new_releases(interactive = true) 235 tracks_playlist = "4agx19QeJFwPQRWeTViq9d" 236 albums_playlist = "2qYpNB8LDicKjcm5Px1dDQ" 237 238 client = SpotifyClient.new 239 artists = client.get_followed_artists 240 releases = client.get_artists_releases(artists) 241 last_checked = YAML.load_file("#{ENV["HOME"]}/.local/share/spot-last-checked", permitted_classes: [Date, Symbol]) 242 albums, others = releases.select { |r| r.release_date >= last_checked }.partition { |x| x.album_type == "album" } 243 244 albums_tracks = albums.reduce([]) do |acc, album| 245 album_tracks = client.api_call_get_unpaginate("albums/#{album.id}/tracks", {limit: 50}) 246 album_tracks.each { |track| track["album"] = album["name"] } 247 acc + album_tracks 248 end 249 250 others_tracks = others.reduce([]) do |acc, album| 251 album_tracks = client.api_call_get_unpaginate("albums/#{album.id}/tracks", {limit: 50}) 252 album_tracks.each { |track| track["album"] = album["name"] } 253 acc + album_tracks 254 end 255 256 if interactive 257 trackfile = Tempfile.create 258 trackfile_path = trackfile.path 259 albumfile = Tempfile.create 260 albumfile_path = albumfile.path 261 262 albums_tracks.each do |t| 263 albumfile.puts([t.artists.map(&:name).join(", "), t.name, t.album, t.uri].join("\t")) 264 end 265 266 others_tracks.each do |t| 267 trackfile.puts([t.artists.map(&:name).join(", "), t.name, t.album, t.uri].join("\t")) 268 end 269 270 trackfile.close 271 albumfile.close 272 273 system("nvim", "-o", albumfile_path, trackfile_path) 274 275 trackfile = File.open(trackfile_path, "r") 276 albumfile = File.open(albumfile_path, "r") 277 albums_tracks = albumfile.readlines.map { {uri: _1.chomp.split("\t").last} } 278 others_tracks = trackfile.readlines.map { {uri: _1.chomp.split("\t").last} } 279 280 trackfile.close 281 albumfile.close 282 File.unlink(trackfile.path) 283 File.unlink(albumfile.path) 284 end 285 286 puts("Processing tracks") 287 client.add_to_playlist_if_not_present(tracks_playlist, others_tracks) 288 puts("Processing albums") 289 client.add_to_playlist_if_not_present(albums_playlist, albums_tracks) 290 File.write("#{ENV["HOME"]}/.local/share/spot-last-checked", YAML.dump(Date.today)) 291 end 292 293 # Bulk follow artists from mpd, accessed using mpc. 294 # Asks you to edit a file with artist names to choose who to follow. 295 def bulk_follow_artists 296 require "tempfile" 297 298 client = SpotifyClient.new 299 puts("Getting followed artists...") 300 already_following = client.get_followed_artists 301 puts("Found #{already_following.size}") 302 303 puts("Getting artists from local library...") 304 all_lines = `mpc listall -f '%albumartist%'`.lines(chomp: true).uniq.reject(&:empty?) 305 puts("Found #{all_lines.size}") 306 puts("Looking up artists on spotify...") 307 artists = [] 308 total = all_lines.size 309 print("Processing 0/#{total}") 310 all_lines.each.with_index do |artist, i| 311 print("\rProcessing #{i + 1}/#{total}: #{artist}") 312 # TODO: in search, maybe look for an artist where I've already liked a song? 313 response = client.api_call_get("search", {q: artist, type: :artist}) 314 found_artists = response["artists"]["items"] 315 if found_artists.nil? 316 warn("No artist found for #{artist}") 317 next 318 end 319 320 found_artist = found_artists[0] 321 if found_artist.nil? 322 warn("No artist found for #{artist}") 323 else 324 found_artist["search_query"] = artist 325 artists << found_artist unless artists.include?(found_artist) 326 end 327 end 328 329 puts("Filtering already followed artists...") 330 artists_to_follow_ignore = if File.exist?("#{ENV["HOME"]}/.local/share/spot-artists-follow-ignore") 331 File.readlines("#{ENV["HOME"]}/.local/share/spot-artists-follow-ignore").map { 332 _1.chomp.split("\t") 333 } 334 else 335 [] 336 end 337 338 artists_to_follow_without_followed_obj = artists 339 .reject { |a| already_following.find { |af| af["uri"] == a["uri"] } } 340 artists_to_follow_without_followed_arr = artists_to_follow_without_followed_obj 341 .map { |a| [a["name"], a["external_urls"]["spotify"], a["search_query"]] } 342 artists_to_follow = artists_to_follow_without_followed_arr - artists_to_follow_ignore 343 344 tmpfile = Tempfile.new("artists_to_follow") 345 begin 346 tmpfile.write( 347 artists_to_follow.map { _1.join("\t") }.join("\n") 348 ) 349 tmpfile.close 350 system(ENV["EDITOR"], tmpfile.path) 351 tmpfile.open 352 new_artists_to_follow = tmpfile 353 .readlines(chomp: true) 354 .reduce([]) do |res, chosen| 355 name, href, _query = chosen.split("\t") 356 res << 357 artists_to_follow_without_followed_obj.find { |a| 358 a["name"] == name && a["external_urls"]["spotify"] == href 359 } 360 end 361 .reject(&:empty?) 362 363 ensure 364 tmpfile.close 365 tmpfile.unlink 366 end 367 368 to_subtract = new_artists_to_follow 369 .map { |a| [a["name"], a["external_urls"]["spotify"], a["search_query"]] } 370 371 to_add_to_ignore = (artists_to_follow - to_subtract) 372 puts("Adding #{to_add_to_ignore.size} artists to ignore file") 373 new_ignore = (artists_to_follow_ignore + to_add_to_ignore).uniq 374 File.write("#{ENV["HOME"]}/.local/share/spot-artists-follow-ignore", new_ignore.map { _1.join("\t") }.join("\n")) 375 376 new_artists_to_follow.each_slice(50) do |artists_by_50| 377 ids = artists_by_50.map { _1["id"] } 378 response = client.api_call_put("me/following", {ids: ids}, {type: :artist}) 379 puts(response) 380 end 381 end