dotfiles

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

commit 400497fac13fb8ced91e7aebf50833d2d64ab564
parent 4bd38d303d47d2d3ae62b88d20e8212d1d449ccc
Author: Alex Balgavy <alex@balgavy.eu>
Date:   Mon,  2 Dec 2024 02:01:04 +0100

libspotify: add interactive mode

Diffstat:
Mscripts/libspotify.rb | 241+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
1 file changed, 150 insertions(+), 91 deletions(-)

diff --git a/scripts/libspotify.rb b/scripts/libspotify.rb @@ -1,11 +1,11 @@ #!/usr/bin/env ruby -require 'uri' -require 'net/http' -require 'openssl' -require 'json' -require 'date' -require 'yaml' -require 'webrick' +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 @@ -19,7 +19,7 @@ end # Client to access Spotify class SpotifyClient def set_token - client_id = 'c747e580651248da8e1035c88b3d2065' + client_id = "c747e580651248da8e1035c88b3d2065" scope = %w[ user-read-private playlist-read-collaborative @@ -38,13 +38,16 @@ class SpotifyClient user-modify-playback-state user-read-currently-playing user-read-recently-played - ].join('%20') - redirect_uri = 'http://localhost:4815/callback' + ] + .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| + 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> @@ -53,21 +56,22 @@ class SpotifyClient </script></body></html> HTML end - server.mount_proc '/token' do |req, res| + + server.mount_proc("/token") do |req, res| res.status = 200 - @token = req.query['access_token'] + @token = req.query["access_token"] server.stop end t = Thread.new { server.start } - puts "If it doesn't open automatically, open this in your browser:\n#{url}" - system 'open', url + puts("If it doesn't open automatically, open this in your browser:\n#{url}") + system("open", url) t.join end def initialize set_token - @base_url = URI('https://api.spotify.com/v1/') + @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 @@ -76,52 +80,52 @@ class SpotifyClient def api_call_get(endpoint, params = {}) url = @base_url + endpoint url.query = URI.encode_www_form(params) - url_call_get url + url_call_get(url) end def api_call_post(endpoint, body) url = @base_url + endpoint - url_call_post url, body + 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 + url_call_put(url, body) end def url_call_get(url) request = Net::HTTP::Get.new(url) - request['Authorization'] = "Bearer #{@token}" + 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["Authorization"] = "Bearer #{@token}" request.body = JSON.dump(body) - request.content_type = 'application/json' + 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["Authorization"] = "Bearer #{@token}" request.body = JSON.dump(body) - request.content_type = 'application/json' + 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' + 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 + res = url_call_get(url) data += res.items url = res.next end @@ -130,7 +134,7 @@ class SpotifyClient url = res[results_key].next until url.nil? - res = url_call_get url + res = url_call_get(url) data += res[results_key].items url = res[results_key].next end @@ -140,141 +144,196 @@ class SpotifyClient end def get_followed_artists - api_call_get_unpaginate 'me/following', { type: :artist, limit: 50 }, '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)| - begin - 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 - rescue Exception => e - puts "Could not process artist #{artist}: #{e}" - acc + print("Processing 0/#{total}") + releases = artists + .each + .with_index + .reduce([]) do |acc, (artist, i)| + begin + 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 + rescue Exception => e + puts("Could not process artist #{artist}: #{e}") + acc + end end - end.reject { |album| album.album_type == 'compilation' } - print "\n" + .reject { |album| album.album_type == "compilation" } + print("\n") - puts 'Sorting' + puts("Sorting") releases.sort_by(&: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." + playlist_track_uris = api_call_get_unpaginate("playlists/#{playlist_id}/tracks", {limit: 50}).map { |x| + x.track.uri + } + track_uris = tracks.map { _1[: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 + body = {:"uris" => uris_slice} + api_call_post("playlists/#{playlist_id}/tracks", body) end end end -def playlist_overview playlist_id +def playlist_overview( + playlist_id # to download: # playlist_id = '52qgFnbZwV36ogaGUquBDt' +) client = SpotifyClient.new - playlist_tracks = client.api_call_get_unpaginate("playlists/#{playlist_id}/tracks", { limit: 50 }) + playlist_tracks = client.api_call_get_unpaginate("playlists/#{playlist_id}/tracks", {limit: 50}) by_artist = playlist_tracks.group_by { _1.track.artists.first.name } - by_artist_album = by_artist.reduce({}) { |h, (artist, tracks)| h[artist] = tracks.group_by { |t| t.track.album.name }; h } + by_artist_album = by_artist.reduce({}) { |h, (artist, tracks)| + h[artist] = tracks.group_by { |t| t.track.album.name } + h + } res = by_artist_album.reduce({}) do |h, (artist, albums)| h[artist] = albums.reduce({}) do |h2, (album, tracks)| h2[album] = tracks.map { |track| track.track.name }.uniq h2 end + h end - puts JSON.dump res + + puts(JSON.dump(res)) 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' +def process_new_releases(interactive = true) + 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' } + releases = client.get_artists_releases(artists) + # last_checked = YAML.load_file("#{ENV["HOME"]}/.local/share/spot-last-checked", permitted_classes: [Date, Symbol]) + last_checked = Date::parse("2024-11-20") + 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 }) + 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 }) + 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)) + if interactive + trackfile = Tempfile.create + trackfile_path = trackfile.path + albumfile = Tempfile.create + albumfile_path = albumfile.path + + albums_tracks.each do |t| + albumfile.puts([t.artists.map(&:name).join(", "), t.name, t.uri].join("\t")) + end + + others_tracks.each do |t| + trackfile.puts([t.artists.map(&:name).join(", "), t.name, t.uri].join("\t")) + end + + trackfile.close + albumfile.close + + system("nvim", "-o", albumfile_path, trackfile_path) + + trackfile = File.open(trackfile_path, "r") + albumfile = File.open(albumfile_path, "r") + albums_tracks = albumfile.readlines.map { {uri: _1.chomp.split("\t").last} } + others_tracks = trackfile.readlines.map { {uri: _1.chomp.split("\t").last} } + + trackfile.close + albumfile.close + File.unlink(trackfile.path) + File.unlink(albumfile.path) + 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' + require "tempfile" + client = SpotifyClient.new - puts 'Getting followed artists...' + puts("Getting followed artists...") already_following = client.get_followed_artists - puts "Found #{already_following.size}" + puts("Found #{already_following.size}") - puts 'Getting artists from local library...' + 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...' + puts("Found #{all_lines.size}") + puts("Looking up artists on spotify...") artists = [] total = all_lines.size - print "Processing 0/#{total}" + print("Processing 0/#{total}") all_lines.each.with_index do |artist, i| - print "\rProcessing #{i + 1}/#{total}: #{artist}" + 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_artists = response['artists']['items'] + response = client.api_call_get("search", {q: artist, type: :artist}) + found_artists = response["artists"]["items"] if found_artists.nil? - warn "No artist found for #{artist}" + warn("No artist found for #{artist}") next end + found_artist = found_artists[0] if found_artist.nil? - warn "No artist found for #{artist}" + warn("No artist found for #{artist}") else - found_artist['search_query'] = artist + 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'] } } + 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') + 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.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 + 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 } + 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 + ids = artists_by_50.map { _1["id"] } + response = client.api_call_put("me/following", {ids: ids}, {type: :artist}) + puts(response) end end