dotfiles

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

libspotify.rb (11786B)


      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 require "byebug"
     10 
     11 # Add easy access to hash members for JSON stuff
     12 class Hash
     13   def method_missing(meth, *_args, &_block)
     14     raise NoMethodError unless key?(meth.to_s)
     15 
     16     self[meth.to_s]
     17   end
     18 end
     19 
     20 # Client to access Spotify
     21 class SpotifyClient
     22   def set_token
     23     client_id = "c747e580651248da8e1035c88b3d2065"
     24     scope = %w[
     25       user-read-private
     26       playlist-read-collaborative
     27       playlist-modify-public
     28       playlist-modify-private
     29       streaming
     30       ugc-image-upload
     31       user-follow-modify
     32       user-follow-read
     33       user-library-read
     34       user-library-modify
     35       user-read-private
     36       user-read-email
     37       user-top-read
     38       user-read-playback-state
     39       user-modify-playback-state
     40       user-read-currently-playing
     41       user-read-recently-played
     42     ]
     43       .join("%20")
     44     redirect_uri = "http://localhost:4815/callback"
     45     url = "https://accounts.spotify.com/authorize?client_id=#{client_id}&response_type=token&scope=#{scope}&show_dialog=false&redirect_uri=#{redirect_uri}"
     46     server = WEBrick::HTTPServer.new(
     47       Port: 4815,
     48       Logger: WEBrick::Log.new("/dev/null"),
     49       AccessLog: []
     50     )
     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 
     61     server.mount_proc("/token") do |req, res|
     62       res.status = 200
     63       @token = req.query["access_token"]
     64       server.stop
     65     end
     66 
     67     t = Thread.new { server.start }
     68     puts("If it doesn't open automatically, open this in your browser:\n#{url}")
     69     system("open", url)
     70     t.join
     71   end
     72 
     73   def initialize
     74     set_token
     75     @base_url = URI("https://api.spotify.com/v1/")
     76     @http = Net::HTTP.new(@base_url.host, @base_url.port)
     77     @http.use_ssl = true
     78     @http.verify_mode = OpenSSL::SSL::VERIFY_NONE
     79   end
     80 
     81   def api_call_get(endpoint, params = {})
     82     url = @base_url + endpoint
     83     url.query = URI.encode_www_form(params)
     84     url_call_get(url)
     85   end
     86 
     87   def api_call_post(endpoint, body)
     88     url = @base_url + endpoint
     89     url_call_post(url, body)
     90   end
     91 
     92   def api_call_put(endpoint, body, params = {})
     93     url = @base_url + endpoint
     94     url.query = URI.encode_www_form(params)
     95     url_call_put(url, body)
     96   end
     97 
     98   def url_call_get(url)
     99     request = Net::HTTP::Get.new(url)
    100     request["Authorization"] = "Bearer #{@token}"
    101     resp = @http.request(request)
    102     if resp.code_type == Net::HTTPTooManyRequests
    103       wait_seconds = resp["Retry-After"].to_i
    104       wait_min = wait_seconds / 60
    105       if wait_min > 30
    106         puts("Rate limited to wait more than half an hour (#{wait_min} min), exiting")
    107         exit(1)
    108       end
    109 
    110       # Wait and retry
    111       sleep(wait_seconds)
    112       return url_call_get(url)
    113     elsif resp.code_type != Net::HTTPOK
    114       puts("Request #{url} returned #{resp}")
    115       exit(1)
    116     end
    117 
    118     JSON.parse(resp.read_body)
    119   end
    120 
    121   def url_call_post(url, body)
    122     request = Net::HTTP::Post.new(url)
    123     request["Authorization"] = "Bearer #{@token}"
    124     request.body = JSON.dump(body)
    125     request.content_type = "application/json"
    126     JSON.parse(@http.request(request).read_body)
    127   end
    128 
    129   def url_call_put(url, body)
    130     request = Net::HTTP::Put.new(url)
    131     request["Authorization"] = "Bearer #{@token}"
    132     request.body = JSON.dump(body)
    133     request.content_type = "application/json"
    134     response_body = @http.request(request).read_body
    135     response_body.nil? ? nil : JSON.parse(response_body)
    136   end
    137 
    138   def api_call_get_unpaginate(endpoint, params, results_key = nil)
    139     res = api_call_get(endpoint, params)
    140     return res if res.key?("error")
    141 
    142     if results_key.nil?
    143       data = res.items
    144       url = res.next
    145 
    146       until url.nil?
    147         res = url_call_get(url)
    148         data += res.items
    149         url = res.next
    150       end
    151     else
    152       data = res[results_key].items
    153       url = res[results_key].next
    154 
    155       until url.nil?
    156         res = url_call_get(url)
    157         data += res[results_key].items
    158         url = res[results_key].next
    159       end
    160     end
    161 
    162     data
    163   end
    164 
    165   def get_followed_artists
    166     api_call_get_unpaginate("me/following", {type: :artist, limit: 50}, "artists")
    167   end
    168 
    169   def get_artists_releases(artists)
    170     total = artists.size
    171     print("Processing 0/#{total}")
    172     releases = artists
    173       .each
    174       .with_index
    175       .reduce([]) do |acc, (artist, i)|
    176         print("\rProcessing #{i + 1}/#{total}")
    177         response = api_call_get(
    178           "artists/#{artist.id}/albums",
    179           {limit: 50, include_groups: "album,single,appears_on"}
    180         )
    181         albums = response.items
    182         albums.each { |album|
    183           album["release_date"] = album.release_date.split("-").size == 1 ? Date.iso8601("#{album.release_date}-01") : Date
    184             .iso8601(album.release_date)
    185         }
    186         acc + albums
    187       end
    188       .reject { |album| album.album_type == "compilation" }
    189     print("\n")
    190 
    191     puts("Sorting")
    192     releases.sort_by(&:release_date)
    193   end
    194 
    195   def add_to_playlist_if_not_present(playlist_id, tracks)
    196     playlist_track_uris = api_call_get_unpaginate("playlists/#{playlist_id}/tracks", {limit: 50}).map { |x|
    197       x.track.uri
    198     }
    199     track_uris = tracks.map { _1[:uri] }
    200     to_add = track_uris.reject { |t| playlist_track_uris.include?(t) }
    201     puts("Adding #{to_add.size} new tracks to playlist.")
    202     to_add.each_slice(100) do |uris_slice|
    203       body = {:"uris" => uris_slice}
    204       api_call_post("playlists/#{playlist_id}/tracks", body)
    205     end
    206   end
    207 end
    208 
    209 def playlist_overview(
    210   playlist_id
    211   # to download:
    212   # playlist_id = '52qgFnbZwV36ogaGUquBDt'
    213 )
    214   client = SpotifyClient.new
    215   playlist_tracks = client.api_call_get_unpaginate("playlists/#{playlist_id}/tracks", {limit: 50})
    216   by_artist = playlist_tracks.group_by { _1.track.artists.first.name }
    217   by_artist_album = by_artist.reduce({}) { |h, (artist, tracks)|
    218     h[artist] = tracks.group_by { |t| t.track.album.name }
    219     h
    220   }
    221   res = by_artist_album.reduce({}) do |h, (artist, albums)|
    222     h[artist] = albums.reduce({}) do |h2, (album, tracks)|
    223       h2[album] = tracks.map { |track| track.track.name }.uniq
    224       h2
    225     end
    226 
    227     h
    228   end
    229 
    230   puts(JSON.dump(res))
    231 end
    232 
    233 # Process new releases since the date in ~/.local/share/spot-last-checked, add
    234 # them to a tracks or albums playlist.
    235 def process_new_releases(interactive = true)
    236   tracks_playlist = "4agx19QeJFwPQRWeTViq9d"
    237   albums_playlist = "2qYpNB8LDicKjcm5Px1dDQ"
    238 
    239   client = SpotifyClient.new
    240   artists = client.get_followed_artists
    241   releases = client.get_artists_releases(artists)
    242   last_checked = YAML.load_file("#{ENV["HOME"]}/.local/share/spot-last-checked", permitted_classes: [Date, Symbol])
    243   albums, others = releases.select { |r| r.release_date >= last_checked }.partition { |x| x.album_type == "album" }
    244 
    245   albums_tracks = albums.reduce([]) do |acc, album|
    246     album_tracks = client.api_call_get_unpaginate("albums/#{album.id}/tracks", {limit: 50})
    247     album_tracks.each { |track| track["album"] = album["name"] }
    248     acc + album_tracks
    249   end
    250 
    251   others_tracks = others.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   if interactive
    258     trackfile = Tempfile.create
    259     trackfile_path = trackfile.path
    260     albumfile = Tempfile.create
    261     albumfile_path = albumfile.path
    262 
    263     albums_tracks.each do |t|
    264       albumfile.puts([t.artists.map(&:name).join(", "), t.name, t.album, t.uri].join("\t"))
    265     end
    266 
    267     others_tracks.each do |t|
    268       trackfile.puts([t.artists.map(&:name).join(", "), t.name, t.album, t.uri].join("\t"))
    269     end
    270 
    271     trackfile.close
    272     albumfile.close
    273 
    274     system("nvim", "-o", albumfile_path, trackfile_path)
    275 
    276     trackfile = File.open(trackfile_path, "r")
    277     albumfile = File.open(albumfile_path, "r")
    278     albums_tracks = albumfile.readlines.map { {uri: _1.chomp.split("\t").last} }
    279     others_tracks = trackfile.readlines.map { {uri: _1.chomp.split("\t").last} }
    280 
    281     trackfile.close
    282     albumfile.close
    283     File.unlink(trackfile.path)
    284     File.unlink(albumfile.path)
    285   end
    286 
    287   puts("Processing tracks")
    288   client.add_to_playlist_if_not_present(tracks_playlist, others_tracks)
    289   puts("Processing albums")
    290   client.add_to_playlist_if_not_present(albums_playlist, albums_tracks)
    291   File.write("#{ENV["HOME"]}/.local/share/spot-last-checked", YAML.dump(Date.today))
    292 end
    293 
    294 # Bulk follow artists from mpd, accessed using mpc.
    295 # Asks you to edit a file with artist names to choose who to follow.
    296 def bulk_follow_artists
    297   require "tempfile"
    298 
    299   client = SpotifyClient.new
    300   puts("Getting followed artists...")
    301   already_following = client.get_followed_artists
    302   puts("Found #{already_following.size}")
    303 
    304   puts("Getting artists from local library...")
    305   all_lines = `mpc listall -f '%albumartist%'`.lines(chomp: true).uniq.reject(&:empty?)
    306   puts("Found #{all_lines.size}")
    307   puts("Looking up artists on spotify...")
    308   artists = []
    309   total = all_lines.size
    310   print("Processing 0/#{total}")
    311   all_lines.each.with_index do |artist, i|
    312     print("\rProcessing #{i + 1}/#{total}: #{artist}")
    313     # TODO: in search, maybe look for an artist where I've already liked a song?
    314     response = client.api_call_get("search", {q: artist, type: :artist})
    315     found_artists = response["artists"]["items"]
    316     if found_artists.nil?
    317       warn("No artist found for #{artist}")
    318       next
    319     end
    320 
    321     found_artist = found_artists[0]
    322     if found_artist.nil?
    323       warn("No artist found for #{artist}")
    324     else
    325       found_artist["search_query"] = artist
    326       artists << found_artist unless artists.include?(found_artist)
    327     end
    328   end
    329 
    330   puts("Filtering already followed artists...")
    331   artists_to_follow_ignore = if File.exist?("#{ENV["HOME"]}/.local/share/spot-artists-follow-ignore")
    332     File.readlines("#{ENV["HOME"]}/.local/share/spot-artists-follow-ignore").map {
    333       _1.chomp.split("\t")
    334     }
    335   else
    336     []
    337   end
    338 
    339   artists_to_follow_without_followed_obj = artists
    340     .reject { |a| already_following.find { |af| af["uri"] == a["uri"] } }
    341   artists_to_follow_without_followed_arr = artists_to_follow_without_followed_obj
    342     .map { |a| [a["name"], a["external_urls"]["spotify"], a["search_query"]] }
    343   artists_to_follow = artists_to_follow_without_followed_arr - artists_to_follow_ignore
    344 
    345   tmpfile = Tempfile.new("artists_to_follow")
    346   begin
    347     tmpfile.write(
    348       artists_to_follow.map { _1.join("\t") }.join("\n")
    349     )
    350     tmpfile.close
    351     system(ENV["EDITOR"], tmpfile.path)
    352     tmpfile.open
    353     new_artists_to_follow = tmpfile
    354       .readlines(chomp: true)
    355       .reduce([]) do |res, chosen|
    356         name, href, _query = chosen.split("\t")
    357         res <<
    358           artists_to_follow_without_followed_obj.find { |a|
    359             a["name"] == name && a["external_urls"]["spotify"] == href
    360           }
    361       end
    362       .reject(&:empty?)
    363 
    364   ensure
    365     tmpfile.close
    366     tmpfile.unlink
    367   end
    368 
    369   to_subtract = new_artists_to_follow
    370     .map { |a| [a["name"], a["external_urls"]["spotify"], a["search_query"]] }
    371 
    372   to_add_to_ignore = (artists_to_follow - to_subtract)
    373   puts("Adding #{to_add_to_ignore.size} artists to ignore file")
    374   new_ignore = (artists_to_follow_ignore + to_add_to_ignore).uniq
    375   File.write("#{ENV["HOME"]}/.local/share/spot-artists-follow-ignore", new_ignore.map { _1.join("\t") }.join("\n"))
    376 
    377   new_artists_to_follow.each_slice(50) do |artists_by_50|
    378     ids = artists_by_50.map { _1["id"] }
    379     response = client.api_call_put("me/following", {ids: ids}, {type: :artist})
    380     puts(response)
    381   end
    382 end