dotfiles

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

libspotify.rb (11769B)


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