libspotify.rb (9073B)
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 18 def respond_to_missing? 19 true 20 end 21 end 22 23 # Client to access Spotify 24 class SpotifyClient 25 def set_token 26 client_id = 'c747e580651248da8e1035c88b3d2065' 27 scope = %w[ 28 user-read-private 29 playlist-read-collaborative 30 playlist-modify-public 31 playlist-modify-private 32 streaming 33 ugc-image-upload 34 user-follow-modify 35 user-follow-read 36 user-library-read 37 user-library-modify 38 user-read-private 39 user-read-email 40 user-top-read 41 user-read-playback-state 42 user-modify-playback-state 43 user-read-currently-playing 44 user-read-recently-played 45 ].join('%20') 46 redirect_uri = 'http://localhost:4815/callback' 47 url = "https://accounts.spotify.com/authorize?client_id=#{client_id}&response_type=token&scope=#{scope}&show_dialog=false&redirect_uri=#{redirect_uri}" 48 server = WEBrick::HTTPServer.new(Port: 4815, 49 Logger: WEBrick::Log.new('/dev/null'), 50 AccessLog: []) 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 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.each.with_index.reduce([]) do |acc, (artist, i)| 154 print "\rProcessing #{i + 1}/#{total}" 155 response = api_call_get "artists/#{artist.id}/albums", { limit: 50, include_groups: 'album,single,appears_on' } 156 albums = response.items 157 albums.each { |album| album['release_date'] = album.release_date.split('-').size == 1 ? Date.iso8601("#{album.release_date}-01") : Date.iso8601(album.release_date) } 158 acc + albums 159 end.reject { |album| album.album_type == 'compilation' } 160 print "\n" 161 162 puts 'Sorting' 163 releases.sort_by(&:release_date) 164 end 165 166 def add_to_playlist_if_not_present(playlist_id, tracks) 167 playlist_track_uris = api_call_get_unpaginate("playlists/#{playlist_id}/tracks", { limit: 50 }).map { |x| x.track.uri } 168 track_uris = tracks.map(&:uri) 169 to_add = track_uris.reject { |t| playlist_track_uris.include? t } 170 puts "Adding #{to_add.size} new tracks to playlist." 171 to_add.each_slice(100) do |uris_slice| 172 body = { 'uris': uris_slice } 173 api_call_post "playlists/#{playlist_id}/tracks", body 174 end 175 end 176 end 177 178 def playlist_overview playlist_id 179 # to download: 180 # playlist_id = '52qgFnbZwV36ogaGUquBDt' 181 client = SpotifyClient.new 182 playlist_tracks = client.api_call_get_unpaginate("playlists/#{playlist_id}/tracks", { limit: 50 }) 183 by_artist = playlist_tracks.group_by { _1.track.artists.first.name } 184 by_artist_album = by_artist.reduce({}) { |h, (artist, tracks)| h[artist] = tracks.group_by { |t| t.track.album.name }; h } 185 res = by_artist_album.reduce({}) do |h, (artist, albums)| 186 h[artist] = albums.reduce({}) do |h2, (album, tracks)| 187 h2[album] = tracks.map { |track| track.track.name }.uniq 188 h2 189 end 190 h 191 end 192 puts JSON.dump res 193 end 194 195 # Process new releases since the date in ~/.local/share/spot-last-checked, add 196 # them to a tracks or albums playlist. 197 def process_new_releases 198 tracks_playlist = '4agx19QeJFwPQRWeTViq9d' 199 albums_playlist = '2qYpNB8LDicKjcm5Px1dDQ' 200 201 client = SpotifyClient.new 202 artists = client.get_followed_artists 203 releases = client.get_artists_releases artists 204 last_checked = YAML.load_file("#{ENV['HOME']}/.local/share/spot-last-checked", permitted_classes: [Date, Symbol]) 205 albums, others = releases.select { |r| r.release_date >= last_checked }.partition { |x| x.album_type == 'album' } 206 207 albums_tracks = albums.reduce([]) do |acc, album| 208 acc + client.api_call_get_unpaginate("albums/#{album.id}/tracks", { limit: 50 }) 209 end 210 211 others_tracks = others.reduce([]) do |acc, album| 212 acc + client.api_call_get_unpaginate("albums/#{album.id}/tracks", { limit: 50 }) 213 end 214 215 puts 'Processing tracks' 216 client.add_to_playlist_if_not_present tracks_playlist, others_tracks 217 puts 'Processing albums' 218 client.add_to_playlist_if_not_present albums_playlist, albums_tracks 219 File.write("#{ENV['HOME']}/.local/share/spot-last-checked", YAML.dump(Date.today)) 220 end 221 222 # Bulk follow artists from mpd, accessed using mpc. 223 # Asks you to edit a file with artist names to choose who to follow. 224 def bulk_follow_artists 225 require 'tempfile' 226 client = SpotifyClient.new 227 puts 'Getting followed artists...' 228 already_following = client.get_followed_artists 229 puts "Found #{already_following.size}" 230 231 puts 'Getting artists from local library...' 232 all_lines = `mpc listall -f '%albumartist%'`.lines(chomp: true).uniq.reject(&:empty?) 233 puts "Found #{all_lines.size}" 234 puts 'Looking up artists on spotify...' 235 artists = [] 236 total = all_lines.size 237 print "Processing 0/#{total}" 238 all_lines.each.with_index do |artist, i| 239 print "\rProcessing #{i + 1}/#{total}: #{artist}" 240 # TODO: in search, maybe look for an artist where I've already liked a song? 241 response = client.api_call_get 'search', { q: artist, type: :artist } 242 found_artists = response['artists']['items'] 243 if found_artists.nil? 244 warn "No artist found for #{artist}" 245 next 246 end 247 found_artist = found_artists[0] 248 if found_artist.nil? 249 warn "No artist found for #{artist}" 250 else 251 found_artist['search_query'] = artist 252 artists << found_artist unless artists.include?(found_artist) 253 end 254 end 255 256 puts 'Filtering already followed artists...' 257 artists_to_follow = artists.reject { |a| already_following.find { |af| af['uri'] == a['uri'] } } 258 259 tmpfile = Tempfile.new('artists_to_follow') 260 begin 261 tmpfile.write(artists_to_follow.map { |a| [a['name'], a['external_urls']['spotify'], a['search_query']].join("\t") }.join("\n")) 262 tmpfile.close 263 system ENV['EDITOR'], tmpfile.path 264 tmpfile.open 265 artists_to_follow = tmpfile.readlines(chomp: true).reduce([]) do |res, chosen| 266 name, href, _query = chosen.split("\t") 267 res << artists_to_follow.find { |a| a['name'] == name && a['external_urls']['spotify'] == href } 268 end 269 ensure 270 tmpfile.close 271 tmpfile.unlink 272 end 273 274 artists_to_follow.each_slice(50) do |artists_by_50| 275 ids = artists_by_50.map { _1['id'] } 276 response = client.api_call_put 'me/following', { ids: ids }, { type: :artist } 277 puts response 278 end 279 end