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