class Drum::SpotifyService

A service implementation that uses the Spotify Web API to query playlists.

Constants

CLIENT_ID_VAR
CLIENT_SECRET_VAR
PLAYLISTS_CHUNK_SIZE
SAVED_TRACKS_CHUNKS_SIZE
TO_SPOTIFY_TRACKS_CHUNK_SIZE
TRACKS_CHUNK_SIZE
UPLOAD_PLAYLIST_TRACKS_CHUNK_SIZE

Public Class Methods

new(cache_dir, fetch_artist_images: false) click to toggle source

Initializes the Spotify service.

@param [String] cache_dir The path to the cache directory (shared by all services) @param [Boolean] fetch_artist_images Whether to fetch artist images (false by default)

# File lib/drum/service/spotify.rb, line 53
def initialize(cache_dir, fetch_artist_images: false)
  @cache_dir = cache_dir / self.name
  @cache_dir.mkdir unless @cache_dir.directory?

  @auth_tokens = PersistentHash.new(@cache_dir / 'auth-tokens.yaml')
  @authenticated = false

  @fetch_artist_images = fetch_artist_images
end

Public Instance Methods

all_sp_library_playlists(offset: 0) click to toggle source

Download helpers

# File lib/drum/service/spotify.rb, line 243
def all_sp_library_playlists(offset: 0)
  sp_playlists = @me.playlists(limit: PLAYLISTS_CHUNK_SIZE, offset: offset)
  unless sp_playlists.empty?
    sp_playlists + self.all_sp_library_playlists(offset: offset + PLAYLISTS_CHUNK_SIZE)
  else
    []
  end
end
all_sp_library_tracks(offset: 0) click to toggle source
# File lib/drum/service/spotify.rb, line 261
def all_sp_library_tracks(offset: 0)
  sp_tracks = @me.saved_tracks(limit: SAVED_TRACKS_CHUNKS_SIZE, offset: offset)
  unless sp_tracks.empty?
    sp_tracks + self.all_sp_library_tracks(offset: offset + SAVED_TRACKS_CHUNKS_SIZE)
  else
    []
  end
end
all_sp_playlist_tracks(sp_playlist, offset: 0) click to toggle source
# File lib/drum/service/spotify.rb, line 252
def all_sp_playlist_tracks(sp_playlist, offset: 0)
  sp_tracks = sp_playlist.tracks(limit: TRACKS_CHUNK_SIZE, offset: offset)
  unless sp_tracks.empty?
    sp_tracks + self.all_sp_playlist_tracks(sp_playlist, offset: offset + TRACKS_CHUNK_SIZE)
  else
    []
  end
end
authenticate() click to toggle source
# File lib/drum/service/spotify.rb, line 205
def authenticate
  if @authenticated
    return
  end

  client_id = ENV[CLIENT_ID_VAR]
  client_secret = ENV[CLIENT_SECRET_VAR]
  
  if client_id.nil? || client_secret.nil?
    raise "Please specify the env vars #{CLIENT_ID_VAR} and #{CLIENT_SECRET_VAR}!"
  end

  self.authenticate_app(client_id, client_secret)
  access_token, refresh_token, token_type = self.authenticate_user(client_id, client_secret)

  me_json = self.fetch_me(access_token, token_type)
  me_json['credentials'] = {
    'token' => access_token,
    'refresh_token' => refresh_token,
    'access_refresh_callback' => Proc.new do |new_token, token_lifetime|
      new_expiry = DateTime.now + (token_lifetime / 86400.0)
      @auth_tokens[:latest] = {
        access_token: new_token,
        refresh_token: refresh_token, # TODO: Refresh token might change too
        token_type: token_type,
        expires_at: new_expiry
      }
    end
  }
  
  @me = RSpotify::User.new(me_json)
  @authenticated = true

  log.info "Successfully logged in to Spotify API as #{me_json['id']}."
end
authenticate_app(client_id, client_secret) click to toggle source

Authentication

# File lib/drum/service/spotify.rb, line 69
def authenticate_app(client_id, client_secret)
  RSpotify.authenticate(client_id, client_secret)
end
authenticate_user(client_id, client_secret) click to toggle source
# File lib/drum/service/spotify.rb, line 176
def authenticate_user(client_id, client_secret)
  existing = @auth_tokens[:latest]

  unless existing.nil? || existing[:expires_at].nil? || existing[:expires_at] < DateTime.now
    log.info 'Skipping authentication...'
    return existing[:access_token], existing[:refresh_token], existing[:token_type]
  end

  unless existing.nil? || existing[:refresh_token].nil?
    log.info 'Authenticating via refresh...'
    self.authenticate_user_via_refresh(client_id, client_secret, existing[:refresh_token])
  else
    log.info 'Authenticating via browser...'
    self.authenticate_user_via_browser(client_id, client_secret)
  end
end
authenticate_user_via_browser(client_id, client_secret) click to toggle source
# File lib/drum/service/spotify.rb, line 96
def authenticate_user_via_browser(client_id, client_secret)
  # Generate a new access refresh token,
  # this might require user interaction. Since the
  # user has to authenticate through the browser
  # via Spotify's website, we use a small embedded
  # HTTP server as a 'callback'.

  port = 17998
  server = WEBrick::HTTPServer.new Port: port
  csrf_state = SecureRandom.hex
  auth_code = nil
  error = nil
  
  server.mount_proc '/callback' do |req, res|
    error = req.query['error']
    auth_code = req.query['code']
    csrf_response = req.query['state']
    
    if error.nil? && !auth_code.nil? && csrf_response == csrf_state
      res.body = 'Successfully got authorization code!'
    else
      res.body = "Could not authorize: #{error} Sorry :("
    end

    server.shutdown
  end
  
  scopes = [
    # Listening History
    'user-read-recently-played',
    'user-top-read',
    # Playlists
    'playlist-modify-private',
    'playlist-read-private',
    'playlist-read-collaborative',
    # Library
    'user-library-modify',
    'user-library-read',
    # User
    'user-read-private'
  ]
  authorize_url = "https://accounts.spotify.com/authorize?client_id=#{client_id}&response_type=code&redirect_uri=http%3A%2F%2Flocalhost:#{port}%2Fcallback&scope=#{scopes.join('%20')}&state=#{csrf_state}"
  Launchy.open(authorize_url)

  trap 'INT' do server.shutdown end
  
  log.info "Launching callback HTTP server on port #{port}, waiting for auth code..."
  server.start
  
  if auth_code.nil?
    raise "Did not get an auth code: #{error}"
  end

  auth_response = RestClient.post('https://accounts.spotify.com/api/token', {
    grant_type: 'authorization_code',
    code: auth_code,
    redirect_uri: "http://localhost:#{port}/callback", # validation only
    client_id: client_id,
    client_secret: client_secret
  })
  
  self.consume_authentication_response(auth_response)
end
authenticate_user_via_refresh(client_id, client_secret, refresh_token) click to toggle source
# File lib/drum/service/spotify.rb, line 160
def authenticate_user_via_refresh(client_id, client_secret, refresh_token)
  # Authenticate the user using an existing (cached)
  # refresh token. This is useful if the user already
  # has been authenticated or a non-interactive authentication
  # is required (e.g. in a CI script).
  encoded = Base64.strict_encode64("#{client_id}:#{client_secret}")
  auth_response = RestClient.post('https://accounts.spotify.com/api/token', {
    grant_type: 'refresh_token',
    refresh_token: refresh_token
  }, {
    'Authorization' => "Basic #{encoded}"
  })

  self.consume_authentication_response(auth_response)
end
consume_authentication_response(auth_response) click to toggle source
# File lib/drum/service/spotify.rb, line 73
def consume_authentication_response(auth_response)
  unless auth_response.code >= 200 && auth_response.code < 300
    raise "Something went wrong while fetching auth token: #{auth_response}"
  end

  auth_json = JSON.parse(auth_response.body)
  access_token = auth_json['access_token']
  refresh_token = auth_json['refresh_token']
  token_type = auth_json['token_type']
  expires_in = auth_json['expires_in'] # seconds
  expires_at = DateTime.now + (expires_in / 86400.0)
  
  @auth_tokens[:latest] = {
    access_token: access_token,
    refresh_token: refresh_token || @auth_tokens[:latest][:refresh_token],
    token_type: token_type,
    expires_at: expires_at
  }
  log.info "Successfully added access token that expires at #{expires_at}."
  
  [access_token, refresh_token, token_type]
end
download(ref) click to toggle source

Service

# File lib/drum/service/spotify.rb, line 545
def download(ref)
  self.authenticate

  case ref.resource_type
  when :special
    case ref.resource_location
    when :playlists
      log.info 'Querying playlists...'
      sp_playlists = self.all_sp_library_playlists

      log.info 'Fetching playlists...'
      Enumerator.new(sp_playlists.length) do |enum|
        sp_playlists.each do |sp_playlist|
          new_playlist = self.from_sp_playlist(sp_playlist)
          enum.yield new_playlist
        end
      end
    when :tracks
      log.info 'Querying saved tracks...'
      sp_saved_tracks = self.all_sp_library_tracks

      log.info 'Fetching saved tracks...'
      new_playlist = Playlist.new(
        name: 'Saved Tracks'
      )
      new_me = self.from_sp_user(@me, new_playlist)
      new_playlist.id = self.from_sp_id(new_me.id, new_playlist)
      new_playlist.author_id = new_me.id
      new_playlist.store_user(new_me)

      sp_saved_tracks.each do |sp_track|
        new_track, new_artists, new_album = self.from_sp_track(sp_track, new_playlist)

        new_artists.each do |new_artist|
          new_playlist.store_artist(new_artist)
        end

        new_playlist.store_album(new_album)
        new_playlist.store_track(new_track)
      end

      [new_playlist]
    else raise "Special resource location '#{ref.resource_location}' cannot be downloaded (yet)"
    end
  when :playlist
    sp_playlist = RSpotify::Playlist.find_by_id(ref.resource_location)
    new_playlist = self.from_sp_playlist(sp_playlist)

    [new_playlist]
  else raise "Resource type '#{ref.resource_type}' cannot be downloaded (yet)"
  end
end
extract_sp_features(sp_track) click to toggle source
# File lib/drum/service/spotify.rb, line 270
def extract_sp_features(sp_track)
  sp_track&.audio_features
end
fetch_me(access_token, token_type) click to toggle source
# File lib/drum/service/spotify.rb, line 193
def fetch_me(access_token, token_type)
  auth_response = RestClient.get('https://api.spotify.com/v1/me', {
    Authorization: "#{token_type} #{access_token}"
  })
  
  unless auth_response.code >= 200 && auth_response.code < 300
    raise "Something went wrong while user data: #{auth_response}"
  end
  
  return JSON.parse(auth_response.body)
end
from_sp_album(sp_album, new_playlist) click to toggle source
# File lib/drum/service/spotify.rb, line 286
def from_sp_album(sp_album, new_playlist)
  new_id = self.from_sp_id(sp_album.id, new_playlist)
  new_album = new_playlist.albums[new_id]
  unless new_album.nil?
    return [new_album, []]
  end

  new_album = Album.new(
    id: self.from_sp_id(sp_album.id, new_playlist),
    name: sp_album.name,
    spotify: AlbumSpotify.new(
      id: sp_album.id,
      image_url: sp_album&.images.first&.dig('url')
    )
  )

  new_artists = sp_album.artists.map do |sp_artist|
    new_artist = self.from_sp_artist(sp_artist, new_playlist)
    new_album.artist_ids << new_artist.id
    new_artist
  end

  [new_album, new_artists]
end
from_sp_artist(sp_artist, new_playlist) click to toggle source
# File lib/drum/service/spotify.rb, line 337
def from_sp_artist(sp_artist, new_playlist)
  new_id = self.from_sp_id(sp_artist.id, new_playlist)
  new_playlist.artists[new_id] || Artist.new(
    id: new_id,
    name: sp_artist.name,
    spotify: ArtistSpotify.new(
      id: sp_artist.id,
      image_url: if @fetch_artist_images
        sp_artist&.images.first&.dig('url')
      else
        nil
      end
    )
  )
end
from_sp_id(sp_id, new_playlist) click to toggle source

TODO: Replace hexdigest id generation with something

that matches e.g. artists or albums with those
already in the playlist.
# File lib/drum/service/spotify.rb, line 282
def from_sp_id(sp_id, new_playlist)
  sp_id.try { |i| Digest::SHA1.hexdigest(sp_id) }
end
from_sp_playlist(sp_playlist, sp_tracks = nil) click to toggle source
# File lib/drum/service/spotify.rb, line 373
def from_sp_playlist(sp_playlist, sp_tracks = nil)
  new_playlist = Playlist.new(
    name: sp_playlist.name,
    description: sp_playlist&.description,
    spotify: PlaylistSpotify.new(
      id: sp_playlist.id,
      public: sp_playlist.public,
      collaborative: sp_playlist.collaborative,
      image_url: begin
        sp_playlist&.images.first&.dig('url')
      rescue StandardError => e
        nil
      end
    )
  )

  new_playlist.id = self.from_sp_id(sp_playlist.id, new_playlist)

  sp_author = sp_playlist&.owner
  unless sp_author.nil?
    new_author = self.from_sp_user(sp_author, new_playlist)
    new_playlist.author_id = new_author.id
    new_playlist.store_user(new_author)
  end

  sp_added_bys = sp_playlist.tracks_added_by
  sp_added_ats = sp_playlist.tracks_added_at

  sp_tracks = sp_tracks || self.all_sp_playlist_tracks(sp_playlist)
  log.info "Got #{sp_tracks.length} playlist track(s) for '#{sp_playlist.name}'..."
  sp_tracks.each do |sp_track|
    new_track, new_artists, new_album = self.from_sp_track(sp_track, new_playlist)
    new_track.added_at = sp_added_ats[sp_track.id]

    sp_added_by = sp_added_bys[sp_track.id]
    unless sp_added_by.nil?
      new_added_by = self.from_sp_user(sp_added_by, new_playlist)
      new_track.added_by = new_added_by.id
      new_playlist.store_user(new_added_by)
    end

    new_artists.each do |new_artist|
      new_playlist.store_artist(new_artist)
    end

    new_playlist.store_album(new_album)
    new_playlist.store_track(new_track)
  end

  new_playlist
end
from_sp_track(sp_track, new_playlist) click to toggle source
# File lib/drum/service/spotify.rb, line 311
def from_sp_track(sp_track, new_playlist)
  new_track = Track.new(
    name: sp_track.name,
    duration_ms: sp_track.duration_ms,
    explicit: sp_track.explicit,
    isrc: sp_track.external_ids&.dig('isrc'),
    spotify: TrackSpotify.new(
      id: sp_track.id
    )
  )

  new_artists = sp_track.artists.map do |sp_artist|
    new_artist = self.from_sp_artist(sp_artist, new_playlist)
    new_track.artist_ids << new_artist.id
    new_artist
  end

  new_album, new_album_artists = self.from_sp_album(sp_track.album, new_playlist)
  new_track.album_id = new_album.id
  new_artists += new_album_artists

  # TODO: Audio features

  [new_track, new_artists, new_album]
end
from_sp_user(sp_user, new_playlist) click to toggle source
# File lib/drum/service/spotify.rb, line 353
def from_sp_user(sp_user, new_playlist)
  new_id = self.from_sp_id(sp_user.id, new_playlist)
  new_playlist.users[new_id] || User.new(
    id: self.from_sp_id(sp_user.id, new_playlist),
    display_name: begin
      sp_user.display_name unless sp_user.id.empty?
    rescue StandardError => e
      nil
    end,
    spotify: UserSpotify.new(
      id: sp_user.id,
      image_url: begin
        sp_user&.images.first&.dig('url')
      rescue StandardError => e
        nil
      end
    )
  )
end
name() click to toggle source
# File lib/drum/service/spotify.rb, line 63
def name
  'spotify'
end
parse_ref(raw_ref) click to toggle source
# File lib/drum/service/spotify.rb, line 530
def parse_ref(raw_ref)
  if raw_ref.is_token
    location = case raw_ref.text
    when "#{self.name}/tracks" then :tracks
    when "#{self.name}/playlists" then :playlists
    else return nil
    end
    Ref.new(self.name, :special, location)
  else
    self.parse_spotify_link(raw_ref.text) || self.parse_spotify_uri(raw_ref.text)
  end
end
parse_resource_type(raw) click to toggle source

Ref parsing

# File lib/drum/service/spotify.rb, line 485
def parse_resource_type(raw)
  case raw
  when 'playlist' then :playlist
  when 'album' then :album
  when 'track' then :track
  when 'user' then :user
  when 'artist' then :artist
  else nil
  end
end
parse_spotify_uri(raw) click to toggle source
# File lib/drum/service/spotify.rb, line 513
def parse_spotify_uri(raw)
  uri = URI(raw)
  unless uri&.scheme == 'spotify'
    return nil
  end

  parsed_path = uri.opaque.split(':')
  unless parsed_path.length == 2
    return nil
  end

  resource_type = self.parse_resource_type(parsed_path[0])
  resource_location = parsed_path[1]

  Ref.new(self.name, resource_type, resource_location)
end
to_sp_track(track, playlist) click to toggle source

Upload helpers

# File lib/drum/service/spotify.rb, line 427
def to_sp_track(track, playlist)
  sp_id = track&.spotify&.id
  unless sp_id.nil?
    # We already have an associated Spotify ID
    RSpotify::Track.find(sp_id)
  else
    # We need to search for the song
    search_phrase = playlist.track_search_phrase(track)
    sp_results = RSpotify::Track.search(search_phrase, limit: 1)
    sp_track = sp_results[0]

    unless sp_track.nil?
      log.info "Matched '#{track.name}' with '#{sp_track.name}' by '#{sp_track.artists.map { |a| a.name }.join(', ')}' from Spotify"
    end

    sp_track
  end
end
to_sp_tracks(tracks, playlist) click to toggle source
# File lib/drum/service/spotify.rb, line 446
def to_sp_tracks(tracks, playlist)
  unless tracks.nil? || tracks.empty?
    sp_tracks = tracks[...TO_SPOTIFY_TRACKS_CHUNK_SIZE].filter_map { |t| self.to_sp_track(t, playlist) }
    sp_tracks + to_sp_tracks(tracks[TO_SPOTIFY_TRACKS_CHUNK_SIZE...], playlist)
  else
    []
  end
end
upload(ref, playlists) click to toggle source
# File lib/drum/service/spotify.rb, line 598
def upload(ref, playlists)
  self.authenticate

  # Note that pushes currently intentionally always create a new playlist
  # TODO: Flag for overwriting (something like -f, --force?)
  #       (the flag should be declared in the CLI and perhaps added
  #       to Service.upload as a parameter)

  unless ref.resource_type == :special && ref.resource_location == :playlists
    raise 'Cannot upload to anything other than @spotify/playlists yet!'
  end

  playlists.each do |playlist|
    self.upload_playlist(playlist)
  end
end
upload_playlist(playlist) click to toggle source
# File lib/drum/service/spotify.rb, line 462
def upload_playlist(playlist)
  sp_playlist = @me.create_playlist!(
    playlist.name,
    description: playlist.description,
    # TODO: Use public/collaborative from playlist?
    public: false,
    collaborative: false
  )

  tracks = playlist.tracks

  log.info "Externalizing #{tracks.length} playlist track(s)..."
  sp_tracks = self.to_sp_tracks(tracks, playlist)

  log.info "Uploading #{sp_tracks.length} playlist track(s)..."
  self.upload_sp_playlist_tracks(sp_tracks, sp_playlist)

  # TODO: Clone the original playlist and insert potentially new Spotify ids
  nil
end
upload_sp_playlist_tracks(sp_tracks, sp_playlist) click to toggle source
# File lib/drum/service/spotify.rb, line 455
def upload_sp_playlist_tracks(sp_tracks, sp_playlist)
  unless sp_tracks.nil? || sp_tracks.empty?
    sp_playlist.add_tracks!(sp_tracks[...UPLOAD_PLAYLIST_TRACKS_CHUNK_SIZE])
    self.upload_sp_playlist_tracks(sp_tracks[UPLOAD_PLAYLIST_TRACKS_CHUNK_SIZE...], sp_playlist)
  end
end