libspotify.rb (9159B)
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 ].join('%20') 42 redirect_uri = 'http://localhost:4815/callback' 43 url = "https://accounts.spotify.com/authorize?client_id=#{client_id}&response_type=token&scope=#{scope}&show_dialog=false&redirect_uri=#{redirect_uri}" 44 server = WEBrick::HTTPServer.new(Port: 4815, 45 Logger: WEBrick::Log.new('/dev/null'), 46 AccessLog: []) 47 server.mount_proc '/callback' do |_req, res| 48 res.body = <<-HTML 49 <!DOCTYPE html> 50 <html><body><script> 51 const hash = window.location.hash.substring(1); 52 fetch('/token?'+hash).then(() => {window.close()}) 53 </script></body></html> 54 HTML 55 end 56 server.mount_proc '/token' do |req, res| 57 res.status = 200 58 @token = req.query['access_token'] 59 server.stop 60 end 61 62 t = Thread.new { server.start } 63 puts "If it doesn't open automatically, open this in your browser:\n#{url}" 64 system 'open', url 65 t.join 66 end 67 68 def initialize 69 set_token 70 @base_url = URI('https://api.spotify.com/v1/') 71 @http = Net::HTTP.new(@base_url.host, @base_url.port) 72 @http.use_ssl = true 73 @http.verify_mode = OpenSSL::SSL::VERIFY_NONE 74 end 75 76 def api_call_get(endpoint, params = {}) 77 url = @base_url + endpoint 78 url.query = URI.encode_www_form(params) 79 url_call_get url 80 end 81 82 def api_call_post(endpoint, body) 83 url = @base_url + endpoint 84 url_call_post url, body 85 end 86 87 def api_call_put(endpoint, body, params = {}) 88 url = @base_url + endpoint 89 url.query = URI.encode_www_form(params) 90 url_call_put url, body 91 end 92 93 def url_call_get(url) 94 request = Net::HTTP::Get.new(url) 95 request['Authorization'] = "Bearer #{@token}" 96 JSON.parse(@http.request(request).read_body) 97 end 98 99 def url_call_post(url, body) 100 request = Net::HTTP::Post.new(url) 101 request['Authorization'] = "Bearer #{@token}" 102 request.body = JSON.dump(body) 103 request.content_type = 'application/json' 104 JSON.parse(@http.request(request).read_body) 105 end 106 107 def url_call_put(url, body) 108 request = Net::HTTP::Put.new(url) 109 request['Authorization'] = "Bearer #{@token}" 110 request.body = JSON.dump(body) 111 request.content_type = 'application/json' 112 JSON.parse(@http.request(request).read_body) 113 end 114 115 def api_call_get_unpaginate(endpoint, params, results_key = nil) 116 res = api_call_get endpoint, params 117 return res if res.key? 'error' 118 119 if results_key.nil? 120 data = res.items 121 url = res.next 122 123 until url.nil? 124 res = url_call_get url 125 data += res.items 126 url = res.next 127 end 128 else 129 data = res[results_key].items 130 url = res[results_key].next 131 132 until url.nil? 133 res = url_call_get url 134 data += res[results_key].items 135 url = res[results_key].next 136 end 137 end 138 139 data 140 end 141 142 def get_followed_artists 143 api_call_get_unpaginate 'me/following', { type: :artist, limit: 50 }, 'artists' 144 end 145 146 def get_artists_releases(artists) 147 total = artists.size 148 print "Processing 0/#{total}" 149 releases = artists.each.with_index.reduce([]) do |acc, (artist, i)| 150 begin 151 print "\rProcessing #{i + 1}/#{total}" 152 response = api_call_get "artists/#{artist.id}/albums", { limit: 50, include_groups: 'album,single,appears_on' } 153 albums = response.items 154 albums.each { |album| album['release_date'] = album.release_date.split('-').size == 1 ? Date.iso8601("#{album.release_date}-01") : Date.iso8601(album.release_date) } 155 acc + albums 156 rescue Exception => e 157 puts "Could not process artist #{artist}: #{e}" 158 acc 159 end 160 end.reject { |album| album.album_type == 'compilation' } 161 print "\n" 162 163 puts 'Sorting' 164 releases.sort_by(&:release_date) 165 end 166 167 def add_to_playlist_if_not_present(playlist_id, tracks) 168 playlist_track_uris = api_call_get_unpaginate("playlists/#{playlist_id}/tracks", { limit: 50 }).map { |x| x.track.uri } 169 track_uris = tracks.map(&:uri) 170 to_add = track_uris.reject { |t| playlist_track_uris.include? t } 171 puts "Adding #{to_add.size} new tracks to playlist." 172 to_add.each_slice(100) do |uris_slice| 173 body = { 'uris': uris_slice } 174 api_call_post "playlists/#{playlist_id}/tracks", body 175 end 176 end 177 end 178 179 def playlist_overview playlist_id 180 # to download: 181 # playlist_id = '52qgFnbZwV36ogaGUquBDt' 182 client = SpotifyClient.new 183 playlist_tracks = client.api_call_get_unpaginate("playlists/#{playlist_id}/tracks", { limit: 50 }) 184 by_artist = playlist_tracks.group_by { _1.track.artists.first.name } 185 by_artist_album = by_artist.reduce({}) { |h, (artist, tracks)| h[artist] = tracks.group_by { |t| t.track.album.name }; h } 186 res = by_artist_album.reduce({}) do |h, (artist, albums)| 187 h[artist] = albums.reduce({}) do |h2, (album, tracks)| 188 h2[album] = tracks.map { |track| track.track.name }.uniq 189 h2 190 end 191 h 192 end 193 puts JSON.dump res 194 end 195 196 # Process new releases since the date in ~/.local/share/spot-last-checked, add 197 # them to a tracks or albums playlist. 198 def process_new_releases 199 tracks_playlist = '4agx19QeJFwPQRWeTViq9d' 200 albums_playlist = '2qYpNB8LDicKjcm5Px1dDQ' 201 202 client = SpotifyClient.new 203 artists = client.get_followed_artists 204 releases = client.get_artists_releases artists 205 last_checked = YAML.load_file("#{ENV['HOME']}/.local/share/spot-last-checked", permitted_classes: [Date, Symbol]) 206 albums, others = releases.select { |r| r.release_date >= last_checked }.partition { |x| x.album_type == 'album' } 207 208 albums_tracks = albums.reduce([]) do |acc, album| 209 acc + client.api_call_get_unpaginate("albums/#{album.id}/tracks", { limit: 50 }) 210 end 211 212 others_tracks = others.reduce([]) do |acc, album| 213 acc + client.api_call_get_unpaginate("albums/#{album.id}/tracks", { limit: 50 }) 214 end 215 216 puts 'Processing tracks' 217 client.add_to_playlist_if_not_present tracks_playlist, others_tracks 218 puts 'Processing albums' 219 client.add_to_playlist_if_not_present albums_playlist, albums_tracks 220 File.write("#{ENV['HOME']}/.local/share/spot-last-checked", YAML.dump(Date.today)) 221 end 222 223 # Bulk follow artists from mpd, accessed using mpc. 224 # Asks you to edit a file with artist names to choose who to follow. 225 def bulk_follow_artists 226 require 'tempfile' 227 client = SpotifyClient.new 228 puts 'Getting followed artists...' 229 already_following = client.get_followed_artists 230 puts "Found #{already_following.size}" 231 232 puts 'Getting artists from local library...' 233 all_lines = `mpc listall -f '%albumartist%'`.lines(chomp: true).uniq.reject(&:empty?) 234 puts "Found #{all_lines.size}" 235 puts 'Looking up artists on spotify...' 236 artists = [] 237 total = all_lines.size 238 print "Processing 0/#{total}" 239 all_lines.each.with_index do |artist, i| 240 print "\rProcessing #{i + 1}/#{total}: #{artist}" 241 # TODO: in search, maybe look for an artist where I've already liked a song? 242 response = client.api_call_get 'search', { q: artist, type: :artist } 243 found_artists = response['artists']['items'] 244 if found_artists.nil? 245 warn "No artist found for #{artist}" 246 next 247 end 248 found_artist = found_artists[0] 249 if found_artist.nil? 250 warn "No artist found for #{artist}" 251 else 252 found_artist['search_query'] = artist 253 artists << found_artist unless artists.include?(found_artist) 254 end 255 end 256 257 puts 'Filtering already followed artists...' 258 artists_to_follow = artists.reject { |a| already_following.find { |af| af['uri'] == a['uri'] } } 259 260 tmpfile = Tempfile.new('artists_to_follow') 261 begin 262 tmpfile.write(artists_to_follow.map { |a| [a['name'], a['external_urls']['spotify'], a['search_query']].join("\t") }.join("\n")) 263 tmpfile.close 264 system ENV['EDITOR'], tmpfile.path 265 tmpfile.open 266 artists_to_follow = tmpfile.readlines(chomp: true).reduce([]) do |res, chosen| 267 name, href, _query = chosen.split("\t") 268 res << artists_to_follow.find { |a| a['name'] == name && a['external_urls']['spotify'] == href } 269 end 270 ensure 271 tmpfile.close 272 tmpfile.unlink 273 end 274 275 artists_to_follow.each_slice(50) do |artists_by_50| 276 ids = artists_by_50.map { _1['id'] } 277 response = client.api_call_put 'me/following', { ids: ids }, { type: :artist } 278 puts response 279 end 280 end