commit 400497fac13fb8ced91e7aebf50833d2d64ab564
parent 4bd38d303d47d2d3ae62b88d20e8212d1d449ccc
Author: Alex Balgavy <alex@balgavy.eu>
Date: Mon, 2 Dec 2024 02:01:04 +0100
libspotify: add interactive mode
Diffstat:
M | scripts/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