dotfiles

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

libspotify.rb (11221B)


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