libspotify.rb (10242B)
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 JSON.parse(@http.request(request).read_body) 117 end 118 119 def api_call_get_unpaginate(endpoint, params, results_key = nil) 120 res = api_call_get(endpoint, params) 121 return res if res.key?("error") 122 123 if results_key.nil? 124 data = res.items 125 url = res.next 126 127 until url.nil? 128 res = url_call_get(url) 129 data += res.items 130 url = res.next 131 end 132 else 133 data = res[results_key].items 134 url = res[results_key].next 135 136 until url.nil? 137 res = url_call_get(url) 138 data += res[results_key].items 139 url = res[results_key].next 140 end 141 end 142 143 data 144 end 145 146 def get_followed_artists 147 api_call_get_unpaginate("me/following", {type: :artist, limit: 50}, "artists") 148 end 149 150 def get_artists_releases(artists) 151 total = artists.size 152 print("Processing 0/#{total}") 153 releases = artists 154 .each 155 .with_index 156 .reduce([]) do |acc, (artist, i)| 157 begin 158 print("\rProcessing #{i + 1}/#{total}") 159 response = api_call_get( 160 "artists/#{artist.id}/albums", 161 {limit: 50, include_groups: "album,single,appears_on"} 162 ) 163 albums = response.items 164 albums.each { |album| 165 album["release_date"] = album.release_date.split("-").size == 1 ? Date.iso8601("#{album.release_date}-01") : Date 166 .iso8601(album.release_date) 167 } 168 acc + albums 169 rescue Exception => e 170 puts("Could not process artist #{artist}: #{e}") 171 acc 172 end 173 end 174 .reject { |album| album.album_type == "compilation" } 175 print("\n") 176 177 puts("Sorting") 178 releases.sort_by(&:release_date) 179 end 180 181 def add_to_playlist_if_not_present(playlist_id, tracks) 182 playlist_track_uris = api_call_get_unpaginate("playlists/#{playlist_id}/tracks", {limit: 50}).map { |x| 183 x.track.uri 184 } 185 track_uris = tracks.map { _1[:uri] } 186 to_add = track_uris.reject { |t| playlist_track_uris.include?(t) } 187 puts("Adding #{to_add.size} new tracks to playlist.") 188 to_add.each_slice(100) do |uris_slice| 189 body = {:"uris" => uris_slice} 190 api_call_post("playlists/#{playlist_id}/tracks", body) 191 end 192 end 193 end 194 195 def playlist_overview( 196 playlist_id 197 # to download: 198 # playlist_id = '52qgFnbZwV36ogaGUquBDt' 199 ) 200 client = SpotifyClient.new 201 playlist_tracks = client.api_call_get_unpaginate("playlists/#{playlist_id}/tracks", {limit: 50}) 202 by_artist = playlist_tracks.group_by { _1.track.artists.first.name } 203 by_artist_album = by_artist.reduce({}) { |h, (artist, tracks)| 204 h[artist] = tracks.group_by { |t| t.track.album.name } 205 h 206 } 207 res = by_artist_album.reduce({}) do |h, (artist, albums)| 208 h[artist] = albums.reduce({}) do |h2, (album, tracks)| 209 h2[album] = tracks.map { |track| track.track.name }.uniq 210 h2 211 end 212 213 h 214 end 215 216 puts(JSON.dump(res)) 217 end 218 219 # Process new releases since the date in ~/.local/share/spot-last-checked, add 220 # them to a tracks or albums playlist. 221 def process_new_releases(interactive = true) 222 tracks_playlist = "4agx19QeJFwPQRWeTViq9d" 223 albums_playlist = "2qYpNB8LDicKjcm5Px1dDQ" 224 225 client = SpotifyClient.new 226 artists = client.get_followed_artists 227 releases = client.get_artists_releases(artists) 228 # last_checked = YAML.load_file("#{ENV["HOME"]}/.local/share/spot-last-checked", permitted_classes: [Date, Symbol]) 229 last_checked = Date::parse("2024-11-20") 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 = artists.reject { |a| already_following.find { |af| af["uri"] == a["uri"] } } 315 316 tmpfile = Tempfile.new("artists_to_follow") 317 begin 318 tmpfile.write( 319 artists_to_follow.map { |a| [a["name"], a["external_urls"]["spotify"], a["search_query"]].join("\t") }.join("\n") 320 ) 321 tmpfile.close 322 system(ENV["EDITOR"], tmpfile.path) 323 tmpfile.open 324 artists_to_follow = tmpfile.readlines(chomp: true).reduce([]) do |res, chosen| 325 name, href, _query = chosen.split("\t") 326 res << artists_to_follow.find { |a| a["name"] == name && a["external_urls"]["spotify"] == href } 327 end 328 329 ensure 330 tmpfile.close 331 tmpfile.unlink 332 end 333 334 artists_to_follow.each_slice(50) do |artists_by_50| 335 ids = artists_by_50.map { _1["id"] } 336 response = client.api_call_put("me/following", {ids: ids}, {type: :artist}) 337 puts(response) 338 end 339 end