dotfiles

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

commit 0a371ec7e7ca44cd089276bae1fd26fb6f1f687e
parent 163a60767e6049321d1dc1906141db84ac38df4c
Author: Alex Balgavy <alex@balgavy.eu>
Date:   Sun, 17 Sep 2023 15:51:01 +0200

libspotify: spotify script

Diffstat:
Ascripts/libspotify.rb | 255+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 255 insertions(+), 0 deletions(-)

diff --git a/scripts/libspotify.rb b/scripts/libspotify.rb @@ -0,0 +1,255 @@ +#!/usr/bin/env ruby +require 'uri' +require 'net/http' +require 'openssl' +require 'json' +require 'date' +require 'yaml' +require 'webrick' + +# Add easy access to hash members for JSON stuff +class Hash + def method_missing(meth, *_args, &_block) + raise NoMethodError unless key?(meth.to_s) + + self[meth.to_s] + end + + def respond_to_missing? + true + end +end + +# Client to access Spotify +class SpotifyClient + def set_token + client_id = 'c747e580651248da8e1035c88b3d2065' + scope = %w[ + user-read-private + playlist-read-collaborative + playlist-modify-public + playlist-modify-private + streaming + ugc-image-upload + user-follow-modify + user-follow-read + user-library-read + user-library-modify + user-read-private + user-read-email + user-top-read + user-read-playback-state + user-modify-playback-state + user-read-currently-playing + user-read-recently-played + ].join('%20') + redirect_uri = 'http://localhost:4815/callback' + url = "https://accounts.spotify.com/authorize?client_id=#{client_id}&response_type=token&scope=#{scope}&show_dialog=false&redirect_uri=#{redirect_uri}" + server = WEBrick::HTTPServer.new(Port: 4815, + Logger: WEBrick::Log.new('/dev/null'), + AccessLog: []) + server.mount_proc '/callback' do |_req, res| + res.body = <<-HTML + <!DOCTYPE html> + <html><body><script> + const hash = window.location.hash.substring(1); + fetch('/token?'+hash).then(() => {window.close()}) + </script></body></html> + HTML + end + server.mount_proc '/token' do |req, res| + res.status = 200 + @token = req.query['access_token'] + server.stop + end + + t = Thread.new { server.start } + system 'open', url + t.join + end + + def initialize + set_token + @base_url = URI('https://api.spotify.com/v1/') + @http = Net::HTTP.new(@base_url.host, @base_url.port) + @http.use_ssl = true + @http.verify_mode = OpenSSL::SSL::VERIFY_NONE + end + + def api_call_get(endpoint, params = {}) + url = @base_url + endpoint + url.query = URI.encode_www_form(params) + url_call_get url + end + + def api_call_post(endpoint, body) + url = @base_url + endpoint + url_call_post url, body + end + + def api_call_put(endpoint, body, params = {}) + url = @base_url + endpoint + url.query = URI.encode_www_form(params) + url_call_put url, body + end + + def url_call_get(url) + request = Net::HTTP::Get.new(url) + request['Authorization'] = "Bearer #{@token}" + JSON.parse(@http.request(request).read_body) + end + + def url_call_post(url, body) + request = Net::HTTP::Post.new(url) + request['Authorization'] = "Bearer #{@token}" + request.body = JSON.dump(body) + request.content_type = 'application/json' + JSON.parse(@http.request(request).read_body) + end + + def url_call_put(url, body) + request = Net::HTTP::Put.new(url) + request['Authorization'] = "Bearer #{@token}" + request.body = JSON.dump(body) + request.content_type = 'application/json' + JSON.parse(@http.request(request).read_body) + end + + def api_call_get_unpaginate(endpoint, params, results_key = nil) + res = api_call_get endpoint, params + return res if res.key? 'error' + + if results_key.nil? + data = res.items + url = res.next + + until url.nil? + res = url_call_get url + data += res.items + url = res.next + end + else + data = res[results_key].items + url = res[results_key].next + + until url.nil? + res = url_call_get url + data += res[results_key].items + url = res[results_key].next + end + end + + data + end + + def get_followed_artists + api_call_get_unpaginate 'me/following', { type: :artist, limit: 50 }, 'artists' + end + + def get_artists_releases(artists) + total = artists.size + print "Processing 0/#{total}" + releases = artists.each.with_index.reduce([]) do |acc, (artist, i)| + print "\rProcessing #{i + 1}/#{total}" + response = api_call_get "artists/#{artist.id}/albums", { limit: 50, include_groups: 'album,single,appears_on' } + albums = response.items + albums.each { |album| album['release_date'] = album.release_date.split('-').size == 1 ? Date.iso8601("#{album.release_date}-01") : Date.iso8601(album.release_date) } + acc + albums + end.reject { |album| album.album_type == 'compilation' } + + puts 'Sorting' + releases.sort_by { |rel| rel.release_date } + end + + def add_to_playlist_if_not_present(playlist_id, tracks) + playlist_track_uris = api_call_get_unpaginate("playlists/#{playlist_id}/tracks", { limit: 50 }).map { |x| x.track.uri } + track_uris = tracks.map(&:uri) + to_add = track_uris.reject { |t| playlist_track_uris.include? t } + puts "Adding #{to_add.size} new tracks to playlist." + to_add.each_slice(100) do |uris_slice| + body = { 'uris': uris_slice } + api_call_post "playlists/#{playlist_id}/tracks", body + end + end +end + +# Process new releases since the date in ~/.local/share/spot-last-checked, add +# them to a tracks or albums playlist. +def process_new_releases + tracks_playlist = '4agx19QeJFwPQRWeTViq9d' + albums_playlist = '2qYpNB8LDicKjcm5Px1dDQ' + + client = SpotifyClient.new + artists = client.get_followed_artists + releases = client.get_artists_releases artists + last_checked = YAML.load_file("#{ENV['HOME']}/.local/share/spot-last-checked", permitted_classes: [Date, Symbol]) + albums, others = releases.select { |r| r.release_date >= last_checked }.partition { |x| x.album_type == 'album' } + + albums_tracks = albums.reduce([]) do |acc, album| + acc + client.api_call_get_unpaginate("albums/#{album.id}/tracks", { limit: 50 }) + end + + others_tracks = others.reduce([]) do |acc, album| + acc + client.api_call_get_unpaginate("albums/#{album.id}/tracks", { limit: 50 }) + end + + puts "Processing tracks" + client.add_to_playlist_if_not_present tracks_playlist, others_tracks + puts "Processing albums" + client.add_to_playlist_if_not_present albums_playlist, albums_tracks + File.write("#{ENV['HOME']}/.local/share/spot-last-checked", YAML.dump(Date.today)) +end + +# Bulk follow artists from mpd, accessed using mpc. +# Asks you to edit a file with artist names to choose who to follow. +def bulk_follow_artists + require 'tempfile' + client = SpotifyClient.new + puts 'Getting followed artists...' + already_following = client.get_followed_artists + puts "Found #{already_following.size}" + + puts 'Getting artists from local library...' + all_lines = `mpc listall -f '%albumartist%'`.lines(chomp: true).uniq.reject(&:empty?) + puts "Found #{all_lines.size}" + puts 'Looking up artists on spotify...' + artists = [] + total = all_lines.size + print "Processing 0/#{total}" + all_lines.each.with_index do |artist, i| + print "\rProcessing #{i + 1}/#{total}: #{artist}" + # TODO: in search, maybe look for an artist where I've already liked a song? + response = client.api_call_get 'search', { q: artist, type: :artist } + found_artist = response['artists']['items'][0] + if found_artist.nil? + warn "No artist found for #{artist}" + else + found_artist['search_query'] = artist + artists << found_artist unless artists.include?(found_artist) + end + end + + puts 'Filtering already followed artists...' + artists_to_follow = artists.reject { |a| already_following.find { |af| af['uri'] == a['uri'] } } + + tmpfile = Tempfile.new('artists_to_follow') + begin + tmpfile.write(artists_to_follow.map { |a| [a['name'], a['external_urls']['spotify'], a['search_query']].join("\t") }.join("\n")) + tmpfile.close + system ENV['EDITOR'], tmpfile.path + tmpfile.open + artists_to_follow = tmpfile.readlines(chomp: true).reduce([]) do |res, chosen| + name, href, _query = chosen.split("\t") + res << artists_to_follow.find { |a| a['name'] == name && a['external_urls']['spotify'] == href } + end + ensure + tmpfile.close + tmpfile.unlink + end + + artists_to_follow.each_slice(50) do |artists_by_50| + ids = artists_by_50.map { _1['id'] } + response = client.api_call_put 'me/following', { ids: ids }, { type: :artist } + puts response + end +end