dotfiles

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

libspotify.rb (10242B)


      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     JSON.parse(@http.request(request).read_body)
    117   end
    118 
    119   def api_call_get_unpaginate(endpoint, params, results_key = nil)
    120     res = api_call_get(endpoint, params)
    121     return res if res.key?("error")
    122 
    123     if results_key.nil?
    124       data = res.items
    125       url = res.next
    126 
    127       until url.nil?
    128         res = url_call_get(url)
    129         data += res.items
    130         url = res.next
    131       end
    132     else
    133       data = res[results_key].items
    134       url = res[results_key].next
    135 
    136       until url.nil?
    137         res = url_call_get(url)
    138         data += res[results_key].items
    139         url = res[results_key].next
    140       end
    141     end
    142 
    143     data
    144   end
    145 
    146   def get_followed_artists
    147     api_call_get_unpaginate("me/following", {type: :artist, limit: 50}, "artists")
    148   end
    149 
    150   def get_artists_releases(artists)
    151     total = artists.size
    152     print("Processing 0/#{total}")
    153     releases = artists
    154       .each
    155       .with_index
    156       .reduce([]) do |acc, (artist, i)|
    157         begin
    158           print("\rProcessing #{i + 1}/#{total}")
    159           response = api_call_get(
    160             "artists/#{artist.id}/albums",
    161             {limit: 50, include_groups: "album,single,appears_on"}
    162           )
    163           albums = response.items
    164           albums.each { |album|
    165             album["release_date"] = album.release_date.split("-").size == 1 ? Date.iso8601("#{album.release_date}-01") : Date
    166               .iso8601(album.release_date)
    167           }
    168           acc + albums
    169         rescue Exception => e
    170           puts("Could not process artist #{artist}: #{e}")
    171           acc
    172         end
    173       end
    174       .reject { |album| album.album_type == "compilation" }
    175     print("\n")
    176 
    177     puts("Sorting")
    178     releases.sort_by(&:release_date)
    179   end
    180 
    181   def add_to_playlist_if_not_present(playlist_id, tracks)
    182     playlist_track_uris = api_call_get_unpaginate("playlists/#{playlist_id}/tracks", {limit: 50}).map { |x|
    183       x.track.uri
    184     }
    185     track_uris = tracks.map { _1[:uri] }
    186     to_add = track_uris.reject { |t| playlist_track_uris.include?(t) }
    187     puts("Adding #{to_add.size} new tracks to playlist.")
    188     to_add.each_slice(100) do |uris_slice|
    189       body = {:"uris" => uris_slice}
    190       api_call_post("playlists/#{playlist_id}/tracks", body)
    191     end
    192   end
    193 end
    194 
    195 def playlist_overview(
    196   playlist_id
    197   # to download:
    198   # playlist_id = '52qgFnbZwV36ogaGUquBDt'
    199 )
    200   client = SpotifyClient.new
    201   playlist_tracks = client.api_call_get_unpaginate("playlists/#{playlist_id}/tracks", {limit: 50})
    202   by_artist = playlist_tracks.group_by { _1.track.artists.first.name }
    203   by_artist_album = by_artist.reduce({}) { |h, (artist, tracks)|
    204     h[artist] = tracks.group_by { |t| t.track.album.name }
    205     h
    206   }
    207   res = by_artist_album.reduce({}) do |h, (artist, albums)|
    208     h[artist] = albums.reduce({}) do |h2, (album, tracks)|
    209       h2[album] = tracks.map { |track| track.track.name }.uniq
    210       h2
    211     end
    212 
    213     h
    214   end
    215 
    216   puts(JSON.dump(res))
    217 end
    218 
    219 # Process new releases since the date in ~/.local/share/spot-last-checked, add
    220 # them to a tracks or albums playlist.
    221 def process_new_releases(interactive = true)
    222   tracks_playlist = "4agx19QeJFwPQRWeTViq9d"
    223   albums_playlist = "2qYpNB8LDicKjcm5Px1dDQ"
    224 
    225   client = SpotifyClient.new
    226   artists = client.get_followed_artists
    227   releases = client.get_artists_releases(artists)
    228   # last_checked = YAML.load_file("#{ENV["HOME"]}/.local/share/spot-last-checked", permitted_classes: [Date, Symbol])
    229   last_checked = Date::parse("2024-11-20")
    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 = artists.reject { |a| already_following.find { |af| af["uri"] == a["uri"] } }
    315 
    316   tmpfile = Tempfile.new("artists_to_follow")
    317   begin
    318     tmpfile.write(
    319       artists_to_follow.map { |a| [a["name"], a["external_urls"]["spotify"], a["search_query"]].join("\t") }.join("\n")
    320     )
    321     tmpfile.close
    322     system(ENV["EDITOR"], tmpfile.path)
    323     tmpfile.open
    324     artists_to_follow = tmpfile.readlines(chomp: true).reduce([]) do |res, chosen|
    325       name, href, _query = chosen.split("\t")
    326       res << artists_to_follow.find { |a| a["name"] == name && a["external_urls"]["spotify"] == href }
    327     end
    328 
    329   ensure
    330     tmpfile.close
    331     tmpfile.unlink
    332   end
    333 
    334   artists_to_follow.each_slice(50) do |artists_by_50|
    335     ids = artists_by_50.map { _1["id"] }
    336     response = client.api_call_put("me/following", {ids: ids}, {type: :artist})
    337     puts(response)
    338   end
    339 end