dotfiles

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

libspotify.rb (14492B)


      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://localhost: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`
    140   end
    141 
    142   def store_refresh_token(token)
    143     system("security add-generic-password -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
    157     auth
    158     @base_url = URI("https://api.spotify.com/v1/")
    159     @http = Net::HTTP.new(@base_url.host, @base_url.port)
    160     @http.use_ssl = true
    161     @http.verify_mode = OpenSSL::SSL::VERIFY_NONE
    162   end
    163 
    164   def api_call_get(endpoint, params = {})
    165     url = @base_url + endpoint
    166     url.query = URI.encode_www_form(params)
    167     url_call_get(url)
    168   end
    169 
    170   def api_call_post(endpoint, body)
    171     url = @base_url + endpoint
    172     url_call_post(url, body)
    173   end
    174 
    175   def api_call_put(endpoint, body, params = {})
    176     url = @base_url + endpoint
    177     url.query = URI.encode_www_form(params)
    178     url_call_put(url, body)
    179   end
    180 
    181   def url_call_get(url)
    182     request = Net::HTTP::Get.new(url)
    183     request["Authorization"] = "Bearer #{@token}"
    184     begin
    185       resp = @http.request(request)
    186     rescue
    187       puts("Connection broke, retrying request to #{url}")
    188       sleep(2)
    189       return url_call_get(url)
    190     end
    191 
    192     if resp.code_type == Net::HTTPTooManyRequests
    193       wait_seconds = resp["Retry-After"].to_i
    194       wait_min = wait_seconds / 60
    195       if wait_min > 30
    196         puts("Rate limited to wait more than half an hour (#{wait_min} min), exiting")
    197         exit(1)
    198       end
    199 
    200       # Wait and retry
    201       sleep(wait_seconds)
    202       return url_call_get(url)
    203     elsif resp.code_type != Net::HTTPOK
    204       puts("Request #{url} returned #{resp}")
    205       exit(1)
    206     end
    207 
    208     JSON.parse(resp.read_body)
    209   end
    210 
    211   def url_call_post(url, body)
    212     request = Net::HTTP::Post.new(url)
    213     request["Authorization"] = "Bearer #{@token}"
    214     request.body = JSON.dump(body)
    215     request.content_type = "application/json"
    216     JSON.parse(@http.request(request).read_body)
    217   end
    218 
    219   def url_call_put(url, body)
    220     request = Net::HTTP::Put.new(url)
    221     request["Authorization"] = "Bearer #{@token}"
    222     request.body = JSON.dump(body)
    223     request.content_type = "application/json"
    224     response_body = @http.request(request).read_body
    225     response_body.nil? ? nil : JSON.parse(response_body)
    226   end
    227 
    228   def api_call_get_unpaginate(endpoint, params, results_key = nil)
    229     res = api_call_get(endpoint, params)
    230     return res if res.key?("error")
    231 
    232     if results_key.nil?
    233       data = res.items
    234       url = res.next
    235 
    236       until url.nil?
    237         res = url_call_get(url)
    238         data += res.items
    239         url = res.next
    240       end
    241     else
    242       data = res[results_key].items
    243       url = res[results_key].next
    244 
    245       until url.nil?
    246         res = url_call_get(url)
    247         data += res[results_key].items
    248         url = res[results_key].next
    249       end
    250     end
    251 
    252     data
    253   end
    254 
    255   def get_followed_artists
    256     api_call_get_unpaginate("me/following", {type: :artist, limit: 50}, "artists")
    257   end
    258 
    259   def get_artists_releases(artists)
    260     total = artists.size
    261     print("Processing 0/#{total}")
    262     releases = artists
    263       .each
    264       .with_index
    265       .reduce([]) do |acc, (artist, i)|
    266         print("\rProcessing #{i + 1}/#{total}")
    267         response = api_call_get(
    268           "artists/#{artist.id}/albums",
    269           {limit: 50, include_groups: "album,single,appears_on"}
    270         )
    271         albums = response.items
    272         albums.each { |album|
    273           album["release_date"] = album.release_date.split("-").size == 1 ? Date.iso8601("#{album.release_date}-01") : Date
    274             .iso8601(album.release_date)
    275         }
    276         acc + albums
    277       end
    278       .reject { |album| album.album_type == "compilation" }
    279     print("\n")
    280 
    281     puts("Sorting")
    282     releases.sort_by(&:release_date)
    283   end
    284 
    285   def add_to_playlist_if_not_present(playlist_id, tracks)
    286     playlist_track_uris = api_call_get_unpaginate("playlists/#{playlist_id}/tracks", {limit: 50}).map { |x|
    287       x.track.uri
    288     }
    289     track_uris = tracks.map { _1[:uri] }
    290     to_add = track_uris.reject { |t| playlist_track_uris.include?(t) }
    291     puts("Adding #{to_add.size} new tracks to playlist.")
    292     to_add.each_slice(100) do |uris_slice|
    293       body = {:"uris" => uris_slice}
    294       api_call_post("playlists/#{playlist_id}/tracks", body)
    295     end
    296   end
    297 end
    298 
    299 def playlist_overview(
    300   playlist_id
    301   # to download:
    302   # playlist_id = '52qgFnbZwV36ogaGUquBDt'
    303 )
    304   client = SpotifyClient.new
    305   playlist_tracks = client.api_call_get_unpaginate("playlists/#{playlist_id}/tracks", {limit: 50})
    306   by_artist = playlist_tracks.group_by { _1.track.artists.first.name }
    307   by_artist_album = by_artist.reduce({}) { |h, (artist, tracks)|
    308     h[artist] = tracks.group_by { |t| t.track.album.name }
    309     h
    310   }
    311   res = by_artist_album.reduce({}) do |h, (artist, albums)|
    312     h[artist] = albums.reduce({}) do |h2, (album, tracks)|
    313       h2[album] = tracks.map { |track| track.track.name }.uniq
    314       h2
    315     end
    316 
    317     h
    318   end
    319 
    320   puts(JSON.dump(res))
    321 end
    322 
    323 # Process new releases since the date in ~/.local/share/spot-last-checked, add
    324 # them to a tracks or albums playlist.
    325 def process_new_releases(interactive = true)
    326   tracks_playlist = "4agx19QeJFwPQRWeTViq9d"
    327   albums_playlist = "2qYpNB8LDicKjcm5Px1dDQ"
    328 
    329   client = SpotifyClient.new
    330   artists = client.get_followed_artists
    331   releases = client.get_artists_releases(artists)
    332   last_checked = YAML.load_file("#{ENV["HOME"]}/.local/share/spot-last-checked", permitted_classes: [Date, Symbol])
    333   albums, others = releases.select { |r| r.release_date >= last_checked }.partition { |x| x.album_type == "album" }
    334 
    335   albums_tracks = albums.reduce([]) do |acc, album|
    336     album_tracks = client.api_call_get_unpaginate("albums/#{album.id}/tracks", {limit: 50})
    337     album_tracks.each { |track| track["album"] = album["name"] }
    338     acc + album_tracks
    339   end
    340 
    341   others_tracks = others.reduce([]) do |acc, album|
    342     album_tracks = client.api_call_get_unpaginate("albums/#{album.id}/tracks", {limit: 50})
    343     album_tracks.each { |track| track["album"] = album["name"] }
    344     acc + album_tracks
    345   end
    346 
    347   if interactive
    348     trackfile = Tempfile.create
    349     trackfile_path = trackfile.path
    350     albumfile = Tempfile.create
    351     albumfile_path = albumfile.path
    352 
    353     albums_tracks.each do |t|
    354       albumfile.puts([t.artists.map(&:name).join(", "), t.name, t.album, t.uri].join("\t"))
    355     end
    356 
    357     others_tracks.each do |t|
    358       trackfile.puts([t.artists.map(&:name).join(", "), t.name, t.album, t.uri].join("\t"))
    359     end
    360 
    361     trackfile.close
    362     albumfile.close
    363 
    364     system("nvim", "-o", albumfile_path, trackfile_path)
    365 
    366     trackfile = File.open(trackfile_path, "r")
    367     albumfile = File.open(albumfile_path, "r")
    368     albums_tracks = albumfile.readlines.map { {uri: _1.chomp.split("\t").last} }
    369     others_tracks = trackfile.readlines.map { {uri: _1.chomp.split("\t").last} }
    370 
    371     trackfile.close
    372     albumfile.close
    373     File.unlink(trackfile.path)
    374     File.unlink(albumfile.path)
    375   end
    376 
    377   puts("Processing tracks")
    378   client.add_to_playlist_if_not_present(tracks_playlist, others_tracks)
    379   puts("Processing albums")
    380   client.add_to_playlist_if_not_present(albums_playlist, albums_tracks)
    381   File.write("#{ENV["HOME"]}/.local/share/spot-last-checked", YAML.dump(Date.today))
    382 end
    383 
    384 # Bulk follow artists from mpd, accessed using mpc.
    385 # Asks you to edit a file with artist names to choose who to follow.
    386 def bulk_follow_artists
    387   require "tempfile"
    388 
    389   client = SpotifyClient.new
    390   puts("Getting followed artists...")
    391   already_following = client.get_followed_artists
    392   puts("Found #{already_following.size}")
    393 
    394   puts("Getting artists from local library...")
    395   all_lines = `mpc listall -f '%albumartist%'`.lines(chomp: true).uniq.reject(&:empty?)
    396   puts("Found #{all_lines.size}")
    397   puts("Looking up artists on spotify...")
    398   artists = []
    399   total = all_lines.size
    400   print("Processing 0/#{total}")
    401   all_lines.each.with_index do |artist, i|
    402     print("\rProcessing #{i + 1}/#{total}: #{artist}")
    403     # TODO: in search, maybe look for an artist where I've already liked a song?
    404     response = client.api_call_get("search", {q: artist, type: :artist})
    405     found_artists = response["artists"]["items"]
    406     if found_artists.nil?
    407       warn("No artist found for #{artist}")
    408       next
    409     end
    410 
    411     found_artist = found_artists[0]
    412     if found_artist.nil?
    413       warn("No artist found for #{artist}")
    414     else
    415       found_artist["search_query"] = artist
    416       artists << found_artist unless artists.include?(found_artist)
    417     end
    418   end
    419 
    420   puts("Filtering already followed artists...")
    421   artists_to_follow_ignore = if File.exist?("#{ENV["HOME"]}/.local/share/spot-artists-follow-ignore")
    422     File.readlines("#{ENV["HOME"]}/.local/share/spot-artists-follow-ignore").map {
    423       _1.chomp.split("\t")
    424     }
    425   else
    426     []
    427   end
    428 
    429   artists_to_follow_without_followed_obj = artists
    430     .reject { |a| already_following.find { |af| af["uri"] == a["uri"] } }
    431   artists_to_follow_without_followed_arr = artists_to_follow_without_followed_obj
    432     .map { |a| [a["name"], a["external_urls"]["spotify"], a["search_query"]] }
    433   artists_to_follow = artists_to_follow_without_followed_arr - artists_to_follow_ignore
    434 
    435   tmpfile = Tempfile.new("artists_to_follow")
    436   begin
    437     tmpfile.write(
    438       artists_to_follow.map { _1.join("\t") }.join("\n")
    439     )
    440     tmpfile.close
    441     system(ENV["EDITOR"], tmpfile.path)
    442     tmpfile.open
    443     new_artists_to_follow = tmpfile
    444       .readlines(chomp: true)
    445       .reduce([]) do |res, chosen|
    446         name, href, _query = chosen.split("\t")
    447         res <<
    448           artists_to_follow_without_followed_obj.find { |a|
    449             a["name"] == name && a["external_urls"]["spotify"] == href
    450           }
    451       end
    452       .reject(&:empty?)
    453 
    454   ensure
    455     tmpfile.close
    456     tmpfile.unlink
    457   end
    458 
    459   to_subtract = new_artists_to_follow
    460     .map { |a| [a["name"], a["external_urls"]["spotify"], a["search_query"]] }
    461 
    462   to_add_to_ignore = (artists_to_follow - to_subtract)
    463   puts("Adding #{to_add_to_ignore.size} artists to ignore file")
    464   new_ignore = (artists_to_follow_ignore + to_add_to_ignore).uniq
    465   File.write("#{ENV["HOME"]}/.local/share/spot-artists-follow-ignore", new_ignore.map { _1.join("\t") }.join("\n"))
    466 
    467   new_artists_to_follow.each_slice(50) do |artists_by_50|
    468     ids = artists_by_50.map { _1["id"] }
    469     response = client.api_call_put("me/following", {ids: ids}, {type: :artist})
    470     puts(response)
    471   end
    472 end