dotfiles

My personal shell configs and stuff
git clone git://git.alex.balgavy.eu/dotfiles.git
Log | Files | Refs | Submodules | README | LICENSE

libspotify.rb (14884B)


      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 require "securerandom"
     20 require "digest"
     21 require "base64"
     22 
     23 # Client to access Spotify
     24 class SpotifyClient
     25   CLIENT_ID = "c747e580651248da8e1035c88b3d2065"
     26   REDIRECT_URI = "http://127.0.0.1:4815/callback"
     27 
     28   # OAUTH functions
     29   def self.generate_random_string(length)
     30     possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
     31     values = SecureRandom.random_bytes(length).bytes
     32     values.reduce("") { |acc, x| acc + possible[x % possible.length] }
     33   end
     34 
     35   def auth_refresh_token(refresh_token)
     36     url = URI("https://accounts.spotify.com/api/token")
     37     body = {
     38       grant_type: :refresh_token,
     39       refresh_token: refresh_token,
     40       client_id: CLIENT_ID
     41     }
     42     request = Net::HTTP::Post.new(url)
     43     request.body = URI.encode_www_form(body)
     44     request.content_type = "application/x-www-form-urlencoded"
     45     http = Net::HTTP::new(url.host, url.port)
     46     http.use_ssl = true
     47     http.verify_mode = OpenSSL::SSL::VERIFY_NONE
     48     response = http.request(request)
     49     resp = JSON.parse(response.read_body)
     50     store_refresh_token(resp["refresh_token"])
     51     return resp["access_token"]
     52   end
     53 
     54   def auth_obtain_code(state, code_challenge)
     55     scope = %w[
     56       user-read-private
     57       playlist-read-collaborative
     58       playlist-modify-public
     59       playlist-modify-private
     60       streaming
     61       ugc-image-upload
     62       user-follow-modify
     63       user-follow-read
     64       user-library-read
     65       user-library-modify
     66       user-read-private
     67       user-read-email
     68       user-top-read
     69       user-read-playback-state
     70       user-modify-playback-state
     71       user-read-currently-playing
     72       user-read-recently-played
     73     ]
     74       .join(" ")
     75     params = {
     76       client_id: CLIENT_ID,
     77       response_type: :code,
     78       scope: scope,
     79       show_dialog: false,
     80       redirect_uri: REDIRECT_URI,
     81       state: state,
     82       code_challenge_method: :S256,
     83       code_challenge: code_challenge
     84     }
     85     url = URI("https://accounts.spotify.com/authorize")
     86     url.query = URI.encode_www_form(params)
     87 
     88     server = WEBrick::HTTPServer.new(
     89       Port: 4815,
     90       Logger: WEBrick::Log.new("/dev/null"),
     91       AccessLog: []
     92     )
     93     server.mount_proc("/callback") do |req, res|
     94       res.status = 200
     95       abort("Mismatched state") if req.query["state"] != state
     96       @auth_code = req.query["code"]
     97       server.stop
     98     end
     99 
    100     t = Thread.new { server.start }
    101     puts("If it doesn't open automatically, open this in your browser:\n#{url}")
    102     system("open", url)
    103     t.join
    104   end
    105 
    106   def auth_request_token(state, code_verifier)
    107     url = URI("https://accounts.spotify.com/api/token")
    108     body = {
    109       grant_type: :authorization_code,
    110       code: @auth_code,
    111       redirect_uri: REDIRECT_URI,
    112       client_id: CLIENT_ID,
    113       code_verifier: code_verifier
    114     }
    115     request = Net::HTTP::Post.new(url)
    116     request.body = URI.encode_www_form(body)
    117     request.content_type = "application/x-www-form-urlencoded"
    118     http = Net::HTTP::new(url.host, url.port)
    119     http.use_ssl = true
    120     http.verify_mode = OpenSSL::SSL::VERIFY_NONE
    121     response = http.request(request)
    122     resp = JSON.parse(response.read_body)
    123     store_refresh_token(resp["refresh_token"])
    124     return resp["access_token"]
    125   end
    126 
    127   # Carry out OAUTH authorization with PKCE flow
    128   def auth_obtain_token
    129     code_verifier = SpotifyClient.generate_random_string(64)
    130     code_challenge = Base64.strict_encode64(Digest::SHA256.digest(code_verifier)).tr("+/", "-_").gsub("=", "")
    131 
    132     state = Base64.strict_encode64(Digest::SHA256.digest(SpotifyClient.generate_random_string(64)))
    133     auth_obtain_code(state, code_challenge)
    134     abort("No auth code") if @auth_code.nil?
    135     return auth_request_token(state, code_verifier)
    136   end
    137 
    138   def get_refresh_token
    139     `security find-generic-password -a spotify -s spotify_refresh_token -w`.strip
    140   end
    141 
    142   def store_refresh_token(token)
    143     system("security add-generic-password -U -a spotify -s spotify_refresh_token -w \"#{token}\"")
    144   end
    145 
    146   def auth
    147     refresh_token = get_refresh_token
    148 
    149     if refresh_token.empty?
    150       @token = auth_obtain_token
    151     else
    152       @token = auth_refresh_token(refresh_token)
    153     end
    154   end
    155 
    156   def initialize_http
    157     @http = Net::HTTP.new(@base_url.host, @base_url.port)
    158     @http.use_ssl = true
    159     @http.verify_mode = OpenSSL::SSL::VERIFY_NONE
    160   end
    161 
    162   def initialize
    163     auth
    164     @base_url = URI("https://api.spotify.com/v1/")
    165     initialize_http
    166   end
    167 
    168   def api_call_get(endpoint, params = {})
    169     url = @base_url + endpoint
    170     url.query = URI.encode_www_form(params)
    171     url_call_get(url)
    172   end
    173 
    174   def api_call_post(endpoint, body)
    175     url = @base_url + endpoint
    176     url_call_post(url, body)
    177   end
    178 
    179   def api_call_put(endpoint, body, params = {})
    180     url = @base_url + endpoint
    181     url.query = URI.encode_www_form(params)
    182     url_call_put(url, body)
    183   end
    184 
    185   def url_call_get(url)
    186     request = Net::HTTP::Get.new(url)
    187     request["Authorization"] = "Bearer #{@token}"
    188     begin
    189       resp = @http.request(request)
    190     rescue Exception => e
    191       puts("Connection broke (#{e}), retrying request to #{url}")
    192       binding.irb if File.exist?("/tmp/rubydebug")
    193 
    194       sleep(2)
    195       initialize_http
    196       return url_call_get(url)
    197     end
    198 
    199     if resp.code_type == Net::HTTPTooManyRequests
    200       wait_seconds = resp["Retry-After"].to_i
    201       wait_min = wait_seconds / 60
    202       if wait_min > 30
    203         puts("Rate limited to wait more than half an hour (#{wait_min} min), exiting")
    204         exit(1)
    205       end
    206 
    207       # Wait and retry
    208       sleep(wait_seconds)
    209       return url_call_get(url)
    210     elsif resp.code_type != Net::HTTPOK
    211       puts("Request #{url} returned #{resp}")
    212       exit(1)
    213     end
    214 
    215     JSON.parse(resp.read_body)
    216   end
    217 
    218   def url_call_post(url, body)
    219     request = Net::HTTP::Post.new(url)
    220     request["Authorization"] = "Bearer #{@token}"
    221     request.body = JSON.dump(body)
    222     request.content_type = "application/json"
    223     begin
    224       JSON.parse(@http.request(request).read_body)
    225     rescue Exception => e
    226       puts("Connection broke (#{e}), retrying request to #{url}")
    227       binding.irb if File.exist?("/tmp/rubydebug")
    228       sleep(2)
    229       initialize_http
    230       return url_call_post(url, body)
    231     end
    232   end
    233 
    234   def url_call_put(url, body)
    235     request = Net::HTTP::Put.new(url)
    236     request["Authorization"] = "Bearer #{@token}"
    237     request.body = JSON.dump(body)
    238     request.content_type = "application/json"
    239     response_body = @http.request(request).read_body
    240     response_body.nil? ? nil : JSON.parse(response_body)
    241   end
    242 
    243   def api_call_get_unpaginate(endpoint, params, results_key = nil)
    244     res = api_call_get(endpoint, params)
    245     return res if res.key?("error")
    246 
    247     if results_key.nil?
    248       data = res.items
    249       url = res.next
    250 
    251       until url.nil?
    252         res = url_call_get(url)
    253         data += res.items
    254         url = res.next
    255       end
    256     else
    257       data = res[results_key].items
    258       url = res[results_key].next
    259 
    260       until url.nil?
    261         res = url_call_get(url)
    262         data += res[results_key].items
    263         url = res[results_key].next
    264       end
    265     end
    266 
    267     data
    268   end
    269 
    270   def get_followed_artists
    271     api_call_get_unpaginate("me/following", {type: :artist, limit: 50}, "artists")
    272   end
    273 
    274   def get_artists_releases(artists)
    275     total = artists.size
    276     print("Processing 0/#{total}")
    277     releases = artists
    278       .each
    279       .with_index
    280       .reduce([]) do |acc, (artist, i)|
    281         print("\rProcessing #{i + 1}/#{total}")
    282         response = api_call_get(
    283           "artists/#{artist.id}/albums",
    284           {limit: 50, include_groups: "album,single,appears_on"}
    285         )
    286         albums = response.items
    287         albums.each { |album|
    288           album["release_date"] = album.release_date.split("-").size == 1 ? Date.iso8601("#{album.release_date}-01") : Date
    289             .iso8601(album.release_date)
    290         }
    291         acc + albums
    292       end
    293       .reject { |album| album.album_type == "compilation" }
    294     print("\n")
    295 
    296     puts("Sorting")
    297     releases.sort_by(&:release_date)
    298   end
    299 
    300   def add_to_playlist_if_not_present(playlist_id, tracks)
    301     playlist_track_uris = api_call_get_unpaginate("playlists/#{playlist_id}/tracks", {limit: 50}).map { |x|
    302       x.track.uri
    303     }
    304     track_uris = tracks.map { _1[:uri] }
    305     to_add = track_uris.reject { |t| playlist_track_uris.include?(t) }
    306     puts("Adding #{to_add.size} new tracks to playlist.")
    307     to_add.each_slice(100) do |uris_slice|
    308       body = {:"uris" => uris_slice}
    309       api_call_post("playlists/#{playlist_id}/tracks", body)
    310     end
    311   end
    312 end
    313 
    314 def playlist_overview(
    315   playlist_id
    316   # to download:
    317   # playlist_id = '52qgFnbZwV36ogaGUquBDt'
    318 )
    319   client = SpotifyClient.new
    320   playlist_tracks = client.api_call_get_unpaginate("playlists/#{playlist_id}/tracks", {limit: 50})
    321   by_artist = playlist_tracks.group_by { _1.track.artists.first.name }
    322   by_artist_album = by_artist.reduce({}) { |h, (artist, tracks)|
    323     h[artist] = tracks.group_by { |t| t.track.album.name }
    324     h
    325   }
    326   res = by_artist_album.reduce({}) do |h, (artist, albums)|
    327     h[artist] = albums.reduce({}) do |h2, (album, tracks)|
    328       h2[album] = tracks.map { |track| track.track.name }.uniq
    329       h2
    330     end
    331 
    332     h
    333   end
    334 
    335   puts(JSON.dump(res))
    336 end
    337 
    338 # Process new releases since the date in ~/.local/share/spot-last-checked, add
    339 # them to a tracks or albums playlist.
    340 def process_new_releases(interactive = true)
    341   tracks_playlist = "4agx19QeJFwPQRWeTViq9d"
    342   albums_playlist = "2qYpNB8LDicKjcm5Px1dDQ"
    343 
    344   client = SpotifyClient.new
    345   artists = client.get_followed_artists
    346   releases = client.get_artists_releases(artists)
    347   last_checked = YAML.load_file("#{ENV["HOME"]}/.local/share/spot-last-checked", permitted_classes: [Date, Symbol])
    348   albums, others = releases.select { |r| r.release_date >= last_checked }.partition { |x| x.album_type == "album" }
    349 
    350   albums_tracks = albums.reduce([]) do |acc, album|
    351     album_tracks = client.api_call_get_unpaginate("albums/#{album.id}/tracks", {limit: 50})
    352     album_tracks.each { |track| track["album"] = album["name"] }
    353     acc + album_tracks
    354   end
    355 
    356   others_tracks = others.reduce([]) do |acc, album|
    357     album_tracks = client.api_call_get_unpaginate("albums/#{album.id}/tracks", {limit: 50})
    358     album_tracks.each { |track| track["album"] = album["name"] }
    359     acc + album_tracks
    360   end
    361 
    362   if interactive
    363     trackfile = Tempfile.create
    364     trackfile_path = trackfile.path
    365     albumfile = Tempfile.create
    366     albumfile_path = albumfile.path
    367 
    368     albums_tracks.each do |t|
    369       albumfile.puts([t.artists.map(&:name).join(", "), t.name, t.album, t.uri].join("\t"))
    370     end
    371 
    372     others_tracks.each do |t|
    373       trackfile.puts([t.artists.map(&:name).join(", "), t.name, t.album, t.uri].join("\t"))
    374     end
    375 
    376     trackfile.close
    377     albumfile.close
    378 
    379     system("nvim", "-o", albumfile_path, trackfile_path)
    380 
    381     trackfile = File.open(trackfile_path, "r")
    382     albumfile = File.open(albumfile_path, "r")
    383     albums_tracks = albumfile.readlines.map { {uri: _1.chomp.split("\t").last} }
    384     others_tracks = trackfile.readlines.map { {uri: _1.chomp.split("\t").last} }
    385 
    386     trackfile.close
    387     albumfile.close
    388     File.unlink(trackfile.path)
    389     File.unlink(albumfile.path)
    390   end
    391 
    392   puts("Processing tracks")
    393   client.add_to_playlist_if_not_present(tracks_playlist, others_tracks)
    394   puts("Processing albums")
    395   client.add_to_playlist_if_not_present(albums_playlist, albums_tracks)
    396   File.write("#{ENV["HOME"]}/.local/share/spot-last-checked", YAML.dump(Date.today))
    397 end
    398 
    399 # Bulk follow artists from mpd, accessed using mpc.
    400 # Asks you to edit a file with artist names to choose who to follow.
    401 def bulk_follow_artists
    402   require "tempfile"
    403 
    404   client = SpotifyClient.new
    405   puts("Getting followed artists...")
    406   already_following = client.get_followed_artists
    407   puts("Found #{already_following.size}")
    408 
    409   puts("Getting artists from local library...")
    410   all_lines = `mpc listall -f '%albumartist%'`.lines(chomp: true).uniq.reject(&:empty?)
    411   puts("Found #{all_lines.size}")
    412   puts("Looking up artists on spotify...")
    413   artists = []
    414   total = all_lines.size
    415   print("Processing 0/#{total}")
    416   all_lines.each.with_index do |artist, i|
    417     print("\rProcessing #{i + 1}/#{total}: #{artist}")
    418     # TODO: in search, maybe look for an artist where I've already liked a song?
    419     response = client.api_call_get("search", {q: artist, type: :artist})
    420     found_artists = response["artists"]["items"]
    421     if found_artists.nil?
    422       warn("No artist found for #{artist}")
    423       next
    424     end
    425 
    426     found_artist = found_artists[0]
    427     if found_artist.nil?
    428       warn("No artist found for #{artist}")
    429     else
    430       found_artist["search_query"] = artist
    431       artists << found_artist unless artists.include?(found_artist)
    432     end
    433   end
    434 
    435   puts("Filtering already followed artists...")
    436   artists_to_follow_ignore = if File.exist?("#{ENV["HOME"]}/.local/share/spot-artists-follow-ignore")
    437     File.readlines("#{ENV["HOME"]}/.local/share/spot-artists-follow-ignore").map {
    438       _1.chomp.split("\t")
    439     }
    440   else
    441     []
    442   end
    443 
    444   artists_to_follow_without_followed_obj = artists
    445     .reject { |a| already_following.find { |af| af["uri"] == a["uri"] } }
    446   artists_to_follow_without_followed_arr = artists_to_follow_without_followed_obj
    447     .map { |a| [a["name"], a["external_urls"]["spotify"], a["search_query"]] }
    448   artists_to_follow = artists_to_follow_without_followed_arr - artists_to_follow_ignore
    449 
    450   tmpfile = Tempfile.new("artists_to_follow")
    451   begin
    452     tmpfile.write(
    453       artists_to_follow.map { _1.join("\t") }.join("\n")
    454     )
    455     tmpfile.close
    456     system(ENV["EDITOR"], tmpfile.path)
    457     tmpfile.open
    458     new_artists_to_follow = tmpfile
    459       .readlines(chomp: true)
    460       .reduce([]) do |res, chosen|
    461         name, href, _query = chosen.split("\t")
    462         res <<
    463           artists_to_follow_without_followed_obj.find { |a|
    464             a["name"] == name && a["external_urls"]["spotify"] == href
    465           }
    466       end
    467       .reject(&:empty?)
    468 
    469   ensure
    470     tmpfile.close
    471     tmpfile.unlink
    472   end
    473 
    474   to_subtract = new_artists_to_follow
    475     .map { |a| [a["name"], a["external_urls"]["spotify"], a["search_query"]] }
    476 
    477   to_add_to_ignore = (artists_to_follow - to_subtract)
    478   puts("Adding #{to_add_to_ignore.size} artists to ignore file")
    479   new_ignore = (artists_to_follow_ignore + to_add_to_ignore).uniq
    480   File.write("#{ENV["HOME"]}/.local/share/spot-artists-follow-ignore", new_ignore.map { _1.join("\t") }.join("\n"))
    481 
    482   new_artists_to_follow.each_slice(50) do |artists_by_50|
    483     ids = artists_by_50.map { _1["id"] }
    484     response = client.api_call_put("me/following", {ids: ids}, {type: :artist})
    485     puts(response)
    486   end
    487 end