Last active
June 29, 2020 08:21
-
-
Save cheshire137/69844669fe791beed2fa to your computer and use it in GitHub Desktop.
Ruby script to create iTunes playlists from your Spotify playlists. Requires a Spotify API app.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env ruby | |
require 'uri' | |
require 'json' | |
require 'net/https' | |
require 'time' | |
require 'cgi' | |
require 'csv' | |
# You need a Spotify API app to have a client ID and client secret. Create | |
# one at https://developer.spotify.com/my-applications/#!/applications/create | |
# For a redirect URI, you can use your Github profile URL, e.g., | |
# https://github.com/moneypenny | |
class WebApi | |
def get uri, headers={} | |
puts "GET #{uri}" | |
http = Net::HTTP.new(uri.host, uri.port) | |
http.use_ssl = uri.scheme == 'https' | |
request = Net::HTTP::Get.new(uri.request_uri, headers) | |
http.request(request) | |
end | |
def post uri, body={}, headers={} | |
puts "POST #{uri}" | |
http = Net::HTTP.new(uri.host, uri.port) | |
http.use_ssl = uri.scheme == 'https' | |
request = Net::HTTP::Post.new(uri.request_uri, headers) | |
request.set_form_data(body) | |
http.request(request) | |
end | |
end | |
class SpotifyApi < WebApi | |
attr_reader :client_id, :client_secret, :redirect_uri, :code, :token, | |
:user_id, :playlist_id | |
def initialize attrs={} | |
attrs.each do |key, value| | |
instance_variable_set "@#{key}", value | |
end | |
end | |
def auth_url | |
"https://accounts.spotify.com/authorize?client_id=#@client_id" + | |
"&response_type=code&redirect_uri=#@redirect_uri" + | |
'&scope=playlist-read-private' | |
end | |
def get_user_id | |
return @user_id if @user_id | |
me_uri = URI.parse('https://api.spotify.com/v1/me') | |
response = get(me_uri, auth_headers) | |
unless response.is_a? Net::HTTPOK | |
puts "Failed to get Spotify user info: #{response.class.name}\n" + | |
"#{response.body}" | |
exit | |
end | |
json = JSON.parse(response.body) | |
@user_id = json['id'] | |
end | |
def get_playlists | |
# TODO: get multiple pages of playlists | |
playlist_uri = URI.parse('https://api.spotify.com/v1/users/' + | |
"#@user_id/playlists?limit=50") | |
response = get(playlist_uri, auth_headers) | |
unless response.is_a? Net::HTTPOK | |
puts "Failed to get Spotify playlists: #{response.class.name}\n" + | |
"#{response.body}" | |
exit | |
end | |
json = JSON.parse(response.body) | |
json['items'] | |
end | |
def get_tracks | |
# TODO: get multiple pages of tracks in a playlist | |
fields = 'items(track(name,album(name),artists(name)))' | |
tracks_uri = URI.parse("https://api.spotify.com/v1/users/#@user_id/" + | |
"playlists/#@playlist_id/tracks?fields=#{fields}") | |
response = get(tracks_uri, auth_headers) | |
unless response.is_a? Net::HTTPOK | |
puts "Failed to get Spotify playlist tracks: #{response.class.name}\n" + | |
"#{response.body}" | |
exit | |
end | |
json = JSON.parse(response.body) | |
json['items'] | |
end | |
def get_token | |
token_uri = URI.parse('https://accounts.spotify.com/api/token') | |
body = {'grant_type' => 'authorization_code', 'code' => @code, | |
'redirect_uri' => @redirect_uri, 'client_id' => @client_id, | |
'client_secret' => @client_secret} | |
response = post(token_uri, body) | |
unless response.is_a? Net::HTTPOK | |
puts "Failed to authenticate with Spotify: #{response.class.name}\n" + | |
"#{response.body}" | |
exit | |
end | |
json = JSON.parse(response.body) | |
@token = json['access_token'] | |
end | |
private | |
def auth_headers | |
{'Authorization' => "Bearer #@token"} | |
end | |
end | |
class ItunesApi < WebApi | |
attr_reader :artists, :track, :album | |
def initialize attrs={} | |
attrs.each do |key, value| | |
instance_variable_set "@#{key}", value | |
end | |
@track = clean_str(@track) if @track | |
@artists = @artists.map {|a| clean_str(a) } if @artists | |
@album = clean_str(@album, true) if @album | |
end | |
def get_track | |
artists_query = CGI.escape(@artists.join(' ')) | |
track_query = CGI.escape(@track) | |
album_query = CGI.escape(@album) | |
query = [artists_query, track_query, album_query]. | |
reject {|str| str.strip.length < 1 }.join('+') | |
uri = URI.parse("https://itunes.apple.com/search?term=#{query}" + | |
'&entity=musicTrack&media=music&limit=1') | |
response = get(uri) | |
unless response.is_a? Net::HTTPOK | |
puts "Failed to find #@track by #{@artists.join(', ')} on album " + | |
"#@album: #{response.class.name}\n#{response.body}" | |
return false | |
end | |
json = JSON.parse(response.body) | |
result_count = json['resultCount'].to_i | |
return false if result_count < 1 | |
json['results'][0] | |
end | |
private | |
def clean_str str, strip_parens=false | |
# 'Big Boi Presents... Got Purp? Vol. 2' => | |
# 'Big Boi Presents Got Purp Vol. 2' | |
str = str.gsub(/\.\.\./, '').gsub(/\?/, '') | |
feat_index = str.downcase.index('feat.') | |
if feat_index | |
# 'Kryptonite - feat. Big Boi' => 'Kryptonite - ' | |
str = str[0...feat_index] | |
end | |
hyphen_index = str.index(' - ') | |
if hyphen_index | |
# 'Kryptonite - ' => 'Kryptonite' | |
str = str[0...hyphen_index] | |
end | |
ellipsis_index = str.index('...') | |
if strip_parens | |
open_index = str.index('(') | |
if open_index | |
close_index = str.index(')', open_index) | |
if close_index | |
# 'Take Care (Explicit Deluxee)' => 'Take Care ' | |
str = str[0...open_index] + str[close_index+1...str.length] | |
end | |
end | |
end | |
# 'Take Care ' => 'Take Care' | |
str.strip | |
end | |
end | |
client_id = ENV['CLIENT_ID'] | |
client_secret = ENV['CLIENT_SECRET'] | |
redirect_uri = ENV['REDIRECT_URI'] | |
code = ENV['CODE'] | |
token = ENV['TOKEN'] | |
playlist_id = ENV['PLAYLIST_ID'] | |
user_id = ENV['USER_ID'] | |
api = SpotifyApi.new(client_id: client_id, client_secret: client_secret, | |
redirect_uri: redirect_uri, code: code, token: token, | |
playlist_id: playlist_id, user_id: user_id) | |
if api.token && api.playlist_id && api.user_id | |
puts '# Step 4' | |
spotify_tracks = api.get_tracks | |
itunes_tracks = [] | |
spotify_tracks.each do |json| | |
spotify_track = json['track'] | |
artist_names = spotify_track['artists'].map {|artist| artist['name'] } | |
album_name = spotify_track['album']['name'] | |
track_name = spotify_track['name'] | |
itunes_api = ItunesApi.new(artists: artist_names, track: track_name, | |
album: album_name) | |
itunes_track = itunes_api.get_track | |
if itunes_track | |
puts "\tFound on iTunes: #{itunes_track['trackName']}\t\t" + | |
"#{itunes_track['artistName']}\t\t#{itunes_track['collectionName']}" | |
itunes_tracks << itunes_track | |
else | |
puts "\tNo match found on iTunes" | |
end | |
end | |
itunes_playlist_file = "itunes-playlist-#{api.playlist_id}.txt" | |
CSV.open(itunes_playlist_file, 'wb', col_sep: "\t") do |csv| | |
csv << ['Name', 'Artist', 'Composer', 'Album', 'Grouping', 'Genre', 'Size', | |
'Time', 'Disc Number', 'Disc Count', 'Track Number', 'Track Count', | |
'Year', 'Date Modified', 'Date Added', 'Bit Rate', 'Sample Rate', | |
'Volume Adjustment', 'Kind', 'Equalizer', 'Comments', 'Plays', | |
'Last Played', 'Skips', 'Last Skipped', 'My Rating', 'Location'] | |
itunes_tracks.each do |itunes_track| | |
name = itunes_track['trackName'] | |
artist = itunes_track['artistName'] | |
album = itunes_track['collectionCensoredName'] # seems to work best | |
genre = nil#itunes_track['primaryGenreName'] | |
time = 255 # denotes streaming file | |
disc_number = nil#(itunes_track['discNumber'] || '').to_s | |
disc_count = nil#(itunes_track['discCount'] || '').to_s | |
track_number = nil#(itunes_track['trackNumber'] || '').to_s | |
track_count = nil#(itunes_track['trackCount'] || '').to_s | |
year = nil | |
kind = 'AAC audio file' | |
begin | |
release_date = DateTime.parse(itunes_track['releaseDate']) | |
year = release_date.year | |
rescue ArgumentError | |
end | |
csv << [name, artist, nil, album, nil, genre, nil, time, disc_number, | |
disc_count, track_number, track_count, year, nil, nil, nil, nil, | |
nil, kind, nil, nil, nil, nil, nil, nil, nil, nil] | |
end | |
end | |
File.open(itunes_playlist_file, 'ab') {|f| f.puts '' } # end with new line | |
puts "Wrote iTunes playlist to file: #{itunes_playlist_file}" | |
elsif api.token | |
user_id = api.get_user_id | |
puts '# Step 3' | |
puts "Your Spotify user ID: #{user_id}" | |
playlists = api.get_playlists | |
puts 'Your Spotify playlists (ID then name):' | |
playlists.each do |json| | |
puts "#{json['id']}\t#{json['name']}" | |
end | |
puts "\nRun this script again with TOKEN, USER_ID, and PLAYLIST_ID " + | |
"\nenvironment variables to get an iTunes playlist for the specified " + | |
"\nSpotify playlist." | |
elsif api.code && api.client_secret && api.client_id && api.redirect_uri | |
token = api.get_token | |
puts '# Step 2' | |
puts "Spotify access token: #{token}" | |
puts "\nRun this script again with TOKEN environment variable." | |
elsif api.client_id && api.redirect_uri | |
puts '# Step 1' | |
puts 'Go to this URL, copy the code parameter after you are redirected:' | |
puts "\t#{api.auth_url}" | |
puts "\nRun this script again with CODE, CLIENT_ID, CLIENT_SECRET, and\n" + | |
'REDIRECT_URI environment variables.' | |
else | |
puts 'Example use:' | |
puts 'CLIENT_ID=yourSpotifyClientId REDIRECT_URI=yourSpotifyRedirectUri ' + | |
__FILE__ | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment