dotfiles

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

libspotify.rb (11906B)


      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     begin
    101       resp = @http.request(request)
    102     rescue
    103       puts("Connection broke, retrying request to #{url}")
    104       sleep(2)
    105       return url_call_get(url)
    106     end
    107 
    108     if resp.code_type == Net::HTTPTooManyRequests
    109       wait_seconds = resp["Retry-After"].to_i
    110       wait_min = wait_seconds / 60
    111       if wait_min > 30
    112         puts("Rate limited to wait more than half an hour (#{wait_min} min), exiting")
    113         exit(1)
    114       end
    115 
    116       # Wait and retry
    117       sleep(wait_seconds)
    118       return url_call_get(url)
    119     elsif resp.code_type != Net::HTTPOK
    120       puts("Request #{url} returned #{resp}")
    121       exit(1)
    122     end
    123 
    124     JSON.parse(resp.read_body)
    125   end
    126 
    127   def url_call_post(url, body)
    128     request = Net::HTTP::Post.new(url)
    129     request["Authorization"] = "Bearer #{@token}"
    130     request.body = JSON.dump(body)
    131     request.content_type = "application/json"
    132     JSON.parse(@http.request(request).read_body)
    133   end
    134 
    135   def url_call_put(url, body)
    136     request = Net::HTTP::Put.new(url)
    137     request["Authorization"] = "Bearer #{@token}"
    138     request.body = JSON.dump(body)
    139     request.content_type = "application/json"
    140     response_body = @http.request(request).read_body
    141     response_body.nil? ? nil : JSON.parse(response_body)
    142   end
    143 
    144   def api_call_get_unpaginate(endpoint, params, results_key = nil)
    145     res = api_call_get(endpoint, params)
    146     return res if res.key?("error")
    147 
    148     if results_key.nil?
    149       data = res.items
    150       url = res.next
    151 
    152       until url.nil?
    153         res = url_call_get(url)
    154         data += res.items
    155         url = res.next
    156       end
    157     else
    158       data = res[results_key].items
    159       url = res[results_key].next
    160 
    161       until url.nil?
    162         res = url_call_get(url)
    163         data += res[results_key].items
    164         url = res[results_key].next
    165       end
    166     end
    167 
    168     data
    169   end
    170 
    171   def get_followed_artists
    172     api_call_get_unpaginate("me/following", {type: :artist, limit: 50}, "artists")
    173   end
    174 
    175   def get_artists_releases(artists)
    176     total = artists.size
    177     print("Processing 0/#{total}")
    178     releases = artists
    179       .each
    180       .with_index
    181       .reduce([]) do |acc, (artist, i)|
    182         print("\rProcessing #{i + 1}/#{total}")
    183         response = api_call_get(
    184           "artists/#{artist.id}/albums",
    185           {limit: 50, include_groups: "album,single,appears_on"}
    186         )
    187         albums = response.items
    188         albums.each { |album|
    189           album["release_date"] = album.release_date.split("-").size == 1 ? Date.iso8601("#{album.release_date}-01") : Date
    190             .iso8601(album.release_date)
    191         }
    192         acc + albums
    193       end
    194       .reject { |album| album.album_type == "compilation" }
    195     print("\n")
    196 
    197     puts("Sorting")
    198     releases.sort_by(&:release_date)
    199   end
    200 
    201   def add_to_playlist_if_not_present(playlist_id, tracks)
    202     playlist_track_uris = api_call_get_unpaginate("playlists/#{playlist_id}/tracks", {limit: 50}).map { |x|
    203       x.track.uri
    204     }
    205     track_uris = tracks.map { _1[:uri] }
    206     to_add = track_uris.reject { |t| playlist_track_uris.include?(t) }
    207     puts("Adding #{to_add.size} new tracks to playlist.")
    208     to_add.each_slice(100) do |uris_slice|
    209       body = {:"uris" => uris_slice}
    210       api_call_post("playlists/#{playlist_id}/tracks", body)
    211     end
    212   end
    213 end
    214 
    215 def playlist_overview(
    216   playlist_id
    217   # to download:
    218   # playlist_id = '52qgFnbZwV36ogaGUquBDt'
    219 )
    220   client = SpotifyClient.new
    221   playlist_tracks = client.api_call_get_unpaginate("playlists/#{playlist_id}/tracks", {limit: 50})
    222   by_artist = playlist_tracks.group_by { _1.track.artists.first.name }
    223   by_artist_album = by_artist.reduce({}) { |h, (artist, tracks)|
    224     h[artist] = tracks.group_by { |t| t.track.album.name }
    225     h
    226   }
    227   res = by_artist_album.reduce({}) do |h, (artist, albums)|
    228     h[artist] = albums.reduce({}) do |h2, (album, tracks)|
    229       h2[album] = tracks.map { |track| track.track.name }.uniq
    230       h2
    231     end
    232 
    233     h
    234   end
    235 
    236   puts(JSON.dump(res))
    237 end
    238 
    239 # Process new releases since the date in ~/.local/share/spot-last-checked, add
    240 # them to a tracks or albums playlist.
    241 def process_new_releases(interactive = true)
    242   tracks_playlist = "4agx19QeJFwPQRWeTViq9d"
    243   albums_playlist = "2qYpNB8LDicKjcm5Px1dDQ"
    244 
    245   client = SpotifyClient.new
    246   artists = client.get_followed_artists
    247   releases = client.get_artists_releases(artists)
    248   last_checked = YAML.load_file("#{ENV["HOME"]}/.local/share/spot-last-checked", permitted_classes: [Date, Symbol])
    249   albums, others = releases.select { |r| r.release_date >= last_checked }.partition { |x| x.album_type == "album" }
    250 
    251   albums_tracks = albums.reduce([]) do |acc, album|
    252     album_tracks = client.api_call_get_unpaginate("albums/#{album.id}/tracks", {limit: 50})
    253     album_tracks.each { |track| track["album"] = album["name"] }
    254     acc + album_tracks
    255   end
    256 
    257   others_tracks = others.reduce([]) do |acc, album|
    258     album_tracks = client.api_call_get_unpaginate("albums/#{album.id}/tracks", {limit: 50})
    259     album_tracks.each { |track| track["album"] = album["name"] }
    260     acc + album_tracks
    261   end
    262 
    263   if interactive
    264     trackfile = Tempfile.create
    265     trackfile_path = trackfile.path
    266     albumfile = Tempfile.create
    267     albumfile_path = albumfile.path
    268 
    269     albums_tracks.each do |t|
    270       albumfile.puts([t.artists.map(&:name).join(", "), t.name, t.album, t.uri].join("\t"))
    271     end
    272 
    273     others_tracks.each do |t|
    274       trackfile.puts([t.artists.map(&:name).join(", "), t.name, t.album, t.uri].join("\t"))
    275     end
    276 
    277     trackfile.close
    278     albumfile.close
    279 
    280     system("nvim", "-o", albumfile_path, trackfile_path)
    281 
    282     trackfile = File.open(trackfile_path, "r")
    283     albumfile = File.open(albumfile_path, "r")
    284     albums_tracks = albumfile.readlines.map { {uri: _1.chomp.split("\t").last} }
    285     others_tracks = trackfile.readlines.map { {uri: _1.chomp.split("\t").last} }
    286 
    287     trackfile.close
    288     albumfile.close
    289     File.unlink(trackfile.path)
    290     File.unlink(albumfile.path)
    291   end
    292 
    293   puts("Processing tracks")
    294   client.add_to_playlist_if_not_present(tracks_playlist, others_tracks)
    295   puts("Processing albums")
    296   client.add_to_playlist_if_not_present(albums_playlist, albums_tracks)
    297   File.write("#{ENV["HOME"]}/.local/share/spot-last-checked", YAML.dump(Date.today))
    298 end
    299 
    300 # Bulk follow artists from mpd, accessed using mpc.
    301 # Asks you to edit a file with artist names to choose who to follow.
    302 def bulk_follow_artists
    303   require "tempfile"
    304 
    305   client = SpotifyClient.new
    306   puts("Getting followed artists...")
    307   already_following = client.get_followed_artists
    308   puts("Found #{already_following.size}")
    309 
    310   puts("Getting artists from local library...")
    311   all_lines = `mpc listall -f '%albumartist%'`.lines(chomp: true).uniq.reject(&:empty?)
    312   puts("Found #{all_lines.size}")
    313   puts("Looking up artists on spotify...")
    314   artists = []
    315   total = all_lines.size
    316   print("Processing 0/#{total}")
    317   all_lines.each.with_index do |artist, i|
    318     print("\rProcessing #{i + 1}/#{total}: #{artist}")
    319     # TODO: in search, maybe look for an artist where I've already liked a song?
    320     response = client.api_call_get("search", {q: artist, type: :artist})
    321     found_artists = response["artists"]["items"]
    322     if found_artists.nil?
    323       warn("No artist found for #{artist}")
    324       next
    325     end
    326 
    327     found_artist = found_artists[0]
    328     if found_artist.nil?
    329       warn("No artist found for #{artist}")
    330     else
    331       found_artist["search_query"] = artist
    332       artists << found_artist unless artists.include?(found_artist)
    333     end
    334   end
    335 
    336   puts("Filtering already followed artists...")
    337   artists_to_follow_ignore = if File.exist?("#{ENV["HOME"]}/.local/share/spot-artists-follow-ignore")
    338     File.readlines("#{ENV["HOME"]}/.local/share/spot-artists-follow-ignore").map {
    339       _1.chomp.split("\t")
    340     }
    341   else
    342     []
    343   end
    344 
    345   artists_to_follow_without_followed_obj = artists
    346     .reject { |a| already_following.find { |af| af["uri"] == a["uri"] } }
    347   artists_to_follow_without_followed_arr = artists_to_follow_without_followed_obj
    348     .map { |a| [a["name"], a["external_urls"]["spotify"], a["search_query"]] }
    349   artists_to_follow = artists_to_follow_without_followed_arr - artists_to_follow_ignore
    350 
    351   tmpfile = Tempfile.new("artists_to_follow")
    352   begin
    353     tmpfile.write(
    354       artists_to_follow.map { _1.join("\t") }.join("\n")
    355     )
    356     tmpfile.close
    357     system(ENV["EDITOR"], tmpfile.path)
    358     tmpfile.open
    359     new_artists_to_follow = tmpfile
    360       .readlines(chomp: true)
    361       .reduce([]) do |res, chosen|
    362         name, href, _query = chosen.split("\t")
    363         res <<
    364           artists_to_follow_without_followed_obj.find { |a|
    365             a["name"] == name && a["external_urls"]["spotify"] == href
    366           }
    367       end
    368       .reject(&:empty?)
    369 
    370   ensure
    371     tmpfile.close
    372     tmpfile.unlink
    373   end
    374 
    375   to_subtract = new_artists_to_follow
    376     .map { |a| [a["name"], a["external_urls"]["spotify"], a["search_query"]] }
    377 
    378   to_add_to_ignore = (artists_to_follow - to_subtract)
    379   puts("Adding #{to_add_to_ignore.size} artists to ignore file")
    380   new_ignore = (artists_to_follow_ignore + to_add_to_ignore).uniq
    381   File.write("#{ENV["HOME"]}/.local/share/spot-artists-follow-ignore", new_ignore.map { _1.join("\t") }.join("\n"))
    382 
    383   new_artists_to_follow.each_slice(50) do |artists_by_50|
    384     ids = artists_by_50.map { _1["id"] }
    385     response = client.api_call_put("me/following", {ids: ids}, {type: :artist})
    386     puts(response)
    387   end
    388 end