dotfiles

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

libspotify.rb (9159B)


      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     ].join('%20')
     42     redirect_uri = 'http://localhost:4815/callback'
     43     url = "https://accounts.spotify.com/authorize?client_id=#{client_id}&response_type=token&scope=#{scope}&show_dialog=false&redirect_uri=#{redirect_uri}"
     44     server = WEBrick::HTTPServer.new(Port: 4815,
     45                                      Logger: WEBrick::Log.new('/dev/null'),
     46                                      AccessLog: [])
     47     server.mount_proc '/callback' do |_req, res|
     48       res.body = <<-HTML
     49         <!DOCTYPE html>
     50         <html><body><script>
     51           const hash = window.location.hash.substring(1);
     52           fetch('/token?'+hash).then(() => {window.close()})
     53         </script></body></html>
     54       HTML
     55     end
     56     server.mount_proc '/token' do |req, res|
     57       res.status = 200
     58       @token = req.query['access_token']
     59       server.stop
     60     end
     61 
     62     t = Thread.new { server.start }
     63     puts "If it doesn't open automatically, open this in your browser:\n#{url}"
     64     system 'open', url
     65     t.join
     66   end
     67 
     68   def initialize
     69     set_token
     70     @base_url = URI('https://api.spotify.com/v1/')
     71     @http = Net::HTTP.new(@base_url.host, @base_url.port)
     72     @http.use_ssl = true
     73     @http.verify_mode = OpenSSL::SSL::VERIFY_NONE
     74   end
     75 
     76   def api_call_get(endpoint, params = {})
     77     url = @base_url + endpoint
     78     url.query = URI.encode_www_form(params)
     79     url_call_get url
     80   end
     81 
     82   def api_call_post(endpoint, body)
     83     url = @base_url + endpoint
     84     url_call_post url, body
     85   end
     86 
     87   def api_call_put(endpoint, body, params = {})
     88     url = @base_url + endpoint
     89     url.query = URI.encode_www_form(params)
     90     url_call_put url, body
     91   end
     92 
     93   def url_call_get(url)
     94     request = Net::HTTP::Get.new(url)
     95     request['Authorization'] = "Bearer #{@token}"
     96     JSON.parse(@http.request(request).read_body)
     97   end
     98 
     99   def url_call_post(url, body)
    100     request = Net::HTTP::Post.new(url)
    101     request['Authorization'] = "Bearer #{@token}"
    102     request.body = JSON.dump(body)
    103     request.content_type = 'application/json'
    104     JSON.parse(@http.request(request).read_body)
    105   end
    106 
    107   def url_call_put(url, body)
    108     request = Net::HTTP::Put.new(url)
    109     request['Authorization'] = "Bearer #{@token}"
    110     request.body = JSON.dump(body)
    111     request.content_type = 'application/json'
    112     JSON.parse(@http.request(request).read_body)
    113   end
    114 
    115   def api_call_get_unpaginate(endpoint, params, results_key = nil)
    116     res = api_call_get endpoint, params
    117     return res if res.key? 'error'
    118 
    119     if results_key.nil?
    120       data = res.items
    121       url = res.next
    122 
    123       until url.nil?
    124         res = url_call_get url
    125         data += res.items
    126         url = res.next
    127       end
    128     else
    129       data = res[results_key].items
    130       url = res[results_key].next
    131 
    132       until url.nil?
    133         res = url_call_get url
    134         data += res[results_key].items
    135         url = res[results_key].next
    136       end
    137     end
    138 
    139     data
    140   end
    141 
    142   def get_followed_artists
    143     api_call_get_unpaginate 'me/following', { type: :artist, limit: 50 }, 'artists'
    144   end
    145 
    146   def get_artists_releases(artists)
    147     total = artists.size
    148     print "Processing 0/#{total}"
    149     releases = artists.each.with_index.reduce([]) do |acc, (artist, i)|
    150       begin
    151         print "\rProcessing #{i + 1}/#{total}"
    152         response = api_call_get "artists/#{artist.id}/albums", { limit: 50, include_groups: 'album,single,appears_on' }
    153         albums = response.items
    154         albums.each { |album| album['release_date'] = album.release_date.split('-').size == 1 ? Date.iso8601("#{album.release_date}-01") : Date.iso8601(album.release_date) }
    155         acc + albums
    156       rescue Exception => e
    157         puts "Could not process artist #{artist}: #{e}"
    158         acc
    159       end
    160     end.reject { |album| album.album_type == 'compilation' }
    161     print "\n"
    162 
    163     puts 'Sorting'
    164     releases.sort_by(&:release_date)
    165   end
    166 
    167   def add_to_playlist_if_not_present(playlist_id, tracks)
    168     playlist_track_uris = api_call_get_unpaginate("playlists/#{playlist_id}/tracks", { limit: 50 }).map { |x| x.track.uri }
    169     track_uris = tracks.map(&:uri)
    170     to_add = track_uris.reject { |t| playlist_track_uris.include? t }
    171     puts "Adding #{to_add.size} new tracks to playlist."
    172     to_add.each_slice(100) do |uris_slice|
    173       body = { 'uris': uris_slice }
    174       api_call_post "playlists/#{playlist_id}/tracks", body
    175     end
    176   end
    177 end
    178 
    179 def playlist_overview playlist_id
    180   # to download:
    181   # playlist_id = '52qgFnbZwV36ogaGUquBDt'
    182   client = SpotifyClient.new
    183   playlist_tracks = client.api_call_get_unpaginate("playlists/#{playlist_id}/tracks", { limit: 50 })
    184   by_artist = playlist_tracks.group_by { _1.track.artists.first.name }
    185   by_artist_album = by_artist.reduce({}) { |h, (artist, tracks)| h[artist] = tracks.group_by { |t| t.track.album.name }; h }
    186   res = by_artist_album.reduce({}) do |h, (artist, albums)|
    187     h[artist] = albums.reduce({}) do |h2, (album, tracks)|
    188       h2[album] = tracks.map { |track| track.track.name }.uniq
    189       h2
    190     end
    191     h
    192   end
    193   puts JSON.dump res
    194 end
    195 
    196 # Process new releases since the date in ~/.local/share/spot-last-checked, add
    197 # them to a tracks or albums playlist.
    198 def process_new_releases
    199   tracks_playlist = '4agx19QeJFwPQRWeTViq9d'
    200   albums_playlist = '2qYpNB8LDicKjcm5Px1dDQ'
    201 
    202   client = SpotifyClient.new
    203   artists = client.get_followed_artists
    204   releases = client.get_artists_releases artists
    205   last_checked = YAML.load_file("#{ENV['HOME']}/.local/share/spot-last-checked", permitted_classes: [Date, Symbol])
    206   albums, others = releases.select { |r| r.release_date >= last_checked }.partition { |x| x.album_type == 'album' }
    207 
    208   albums_tracks = albums.reduce([]) do |acc, album|
    209     acc + client.api_call_get_unpaginate("albums/#{album.id}/tracks", { limit: 50 })
    210   end
    211 
    212   others_tracks = others.reduce([]) do |acc, album|
    213     acc + client.api_call_get_unpaginate("albums/#{album.id}/tracks", { limit: 50 })
    214   end
    215 
    216   puts 'Processing tracks'
    217   client.add_to_playlist_if_not_present tracks_playlist, others_tracks
    218   puts 'Processing albums'
    219   client.add_to_playlist_if_not_present albums_playlist, albums_tracks
    220   File.write("#{ENV['HOME']}/.local/share/spot-last-checked", YAML.dump(Date.today))
    221 end
    222 
    223 # Bulk follow artists from mpd, accessed using mpc.
    224 # Asks you to edit a file with artist names to choose who to follow.
    225 def bulk_follow_artists
    226   require 'tempfile'
    227   client = SpotifyClient.new
    228   puts 'Getting followed artists...'
    229   already_following = client.get_followed_artists
    230   puts "Found #{already_following.size}"
    231 
    232   puts 'Getting artists from local library...'
    233   all_lines = `mpc listall -f '%albumartist%'`.lines(chomp: true).uniq.reject(&:empty?)
    234   puts "Found #{all_lines.size}"
    235   puts 'Looking up artists on spotify...'
    236   artists = []
    237   total = all_lines.size
    238   print "Processing 0/#{total}"
    239   all_lines.each.with_index do |artist, i|
    240     print "\rProcessing #{i + 1}/#{total}: #{artist}"
    241     # TODO: in search, maybe look for an artist where I've already liked a song?
    242     response = client.api_call_get 'search', { q: artist, type: :artist }
    243     found_artists = response['artists']['items']
    244     if found_artists.nil?
    245       warn "No artist found for #{artist}"
    246       next
    247     end
    248     found_artist = found_artists[0]
    249     if found_artist.nil?
    250       warn "No artist found for #{artist}"
    251     else
    252       found_artist['search_query'] = artist
    253       artists << found_artist unless artists.include?(found_artist)
    254     end
    255   end
    256 
    257   puts 'Filtering already followed artists...'
    258   artists_to_follow = artists.reject { |a| already_following.find { |af| af['uri'] == a['uri'] } }
    259 
    260   tmpfile = Tempfile.new('artists_to_follow')
    261   begin
    262     tmpfile.write(artists_to_follow.map { |a| [a['name'], a['external_urls']['spotify'], a['search_query']].join("\t") }.join("\n"))
    263     tmpfile.close
    264     system ENV['EDITOR'], tmpfile.path
    265     tmpfile.open
    266     artists_to_follow = tmpfile.readlines(chomp: true).reduce([]) do |res, chosen|
    267       name, href, _query = chosen.split("\t")
    268       res << artists_to_follow.find { |a| a['name'] == name && a['external_urls']['spotify'] == href }
    269     end
    270   ensure
    271     tmpfile.close
    272     tmpfile.unlink
    273   end
    274 
    275   artists_to_follow.each_slice(50) do |artists_by_50|
    276     ids = artists_by_50.map { _1['id'] }
    277     response = client.api_call_put 'me/following', { ids: ids }, { type: :artist }
    278     puts response
    279   end
    280 end