Module: Asciidoctor::BeautifyUri::Providers::Spotify

Defined in:
lib/asciidoctor/beautify_uri/providers/spotify.rb

Overview

uri::spotify:track/[] — resolves live via Spotify's free, unauthenticated oEmbed endpoint by default (title only — oEmbed does not return an artist name for a track). When the author supplies Spotify Web API client credentials (spotify-client-id/spotify-client-secret document attributes, or SPOTIFY_CLIENT_ID/SPOTIFY_CLIENT_SECRET env vars), a Client Credentials Grant lookup is used instead, adding the artist name. Both paths are live, unauthenticated-or-not — neither one caches anything between builds.

Constant Summary collapse

OEMBED_ENDPOINT =
'https://open.spotify.com/oembed'
TOKEN_ENDPOINT =
'https://accounts.spotify.com/api/token'
API_BASE =
'https://api.spotify.com/v1'
HTTP_TIMEOUT =
8
API_RESOURCE_TYPES =

open.spotify.com's own web URLs (and this macro's own path syntax, matching them: uri::spotify:track/[]) use the singular resource name — "track", "album", "episode" — but the Web API's REST endpoints are plural: /v1/tracks/id, not /v1/track/id. Confirmed against the real API (a live credentialed request against the singular path returns a 404 "Service not found"), not assumed from documentation — this is exactly the kind of thing that stays invisible until the credentialed path is actually exercised, since resolve_via_web_api falls back to oEmbed on any failure.

{ 'track' => 'tracks', 'album' => 'albums', 'artist' => 'artists',
'episode' => 'episodes', 'show' => 'shows', 'playlist' => 'playlists' }.freeze

Class Method Summary collapse

Class Method Details

.api_path_for(path) ⇒ Object

See API_RESOURCE_TYPES — the macro's own path syntax and open.spotify.com's web URLs use the singular resource name; the Web API's REST endpoints need it pluralized.



114
115
116
117
# File 'lib/asciidoctor/beautify_uri/providers/spotify.rb', line 114

def self.api_path_for(path)
  type, id = path.split('/', 2)
  %(#{API_RESOURCE_TYPES[type] || "#{type}s"}/#{id})
end

.display_nameObject



35
# File 'lib/asciidoctor/beautify_uri/providers/spotify.rb', line 35

def self.display_name = 'Spotify'

.fetch_access_token(client_id, client_secret) ⇒ Object



134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
# File 'lib/asciidoctor/beautify_uri/providers/spotify.rb', line 134

def self.fetch_access_token(client_id, client_secret)
  uri = URI(TOKEN_ENDPOINT)
  http = Net::HTTP.new(uri.host, uri.port)
  http.use_ssl = true
  http.open_timeout = HTTP_TIMEOUT
  http.read_timeout = HTTP_TIMEOUT
  request = Net::HTTP::Post.new(uri)
  request.basic_auth(client_id, client_secret)
  request.set_form_data('grant_type' => 'client_credentials')
  response = http.request(request)
  return nil unless response.is_a?(Net::HTTPSuccess)

  JSON.parse(response.body)['access_token']
rescue StandardError => e
  Logging.debug(%(spotify token request failed: #{e.message}))
  nil
end

.get_json(uri, headers: {}) ⇒ Object



152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
# File 'lib/asciidoctor/beautify_uri/providers/spotify.rb', line 152

def self.get_json(uri, headers: {})
  http = Net::HTTP.new(uri.host, uri.port)
  http.use_ssl = uri.scheme == 'https'
  http.open_timeout = HTTP_TIMEOUT
  http.read_timeout = HTTP_TIMEOUT
  request = Net::HTTP::Get.new(uri)
  headers.each { |k, v| request[k] = v }
  response = http.request(request)
  return nil unless response.is_a?(Net::HTTPSuccess)

  JSON.parse(response.body)
rescue StandardError => e
  Logging.debug(%(spotify request to #{uri} failed: #{e.message}))
  nil
end

.icon_blockObject



38
39
40
41
42
43
# File 'lib/asciidoctor/beautify_uri/providers/spotify.rb', line 38

def self.icon_block
  # Spotify's own guidelines require no dedicated contrast badge for
  # the icon; Spotify Green is approved directly on black or white
  # backgrounds, which is what this card's frame ever sits on.
  %(<span class="uri-card-icon">#{CardRenderer.icon 'spotify'}</span>)
end

.keyObject



34
# File 'lib/asciidoctor/beautify_uri/providers/spotify.rb', line 34

def self.key = 'spotify'


36
# File 'lib/asciidoctor/beautify_uri/providers/spotify.rb', line 36

def self.link_text = 'Play on Spotify'

.pdf_thumbnail_radius_ptObject

https://developer.spotify.com/documentation/design: "Artwork corners must be rounded to create optical blending with nearby UI elements. Small & medium devices should use a 4px corner radius, whereas large devices should use a 8px corner radius." This is a brand requirement, not a visual preference — unlike the generic uri_card.thumbnail_border_radius theme key (which a theme is free to set for providers with no such rule), a document/theme author cannot override this value for Spotify specifically. 4px (the small/medium figure) since this card's artwork is always compact — a few lines of body text tall — never a large, standalone piece of artwork.



55
# File 'lib/asciidoctor/beautify_uri/providers/spotify.rb', line 55

def self.pdf_thumbnail_radius_pt = 4

.resolve(path, doc) ⇒ Object



57
58
59
60
61
62
63
64
65
66
67
68
69
# File 'lib/asciidoctor/beautify_uri/providers/spotify.rb', line 57

def self.resolve(path, doc)
  return nil if path.nil? || path.empty?

  canonical_url = "https://open.spotify.com/#{path}"
  client_id = doc.attr('spotify-client-id') || ENV['SPOTIFY_CLIENT_ID']
  client_secret = doc.attr('spotify-client-secret') || ENV['SPOTIFY_CLIENT_SECRET']

  if client_id && client_secret
    resolve_via_web_api(path, canonical_url, client_id, client_secret)
  else
    resolve_via_oembed(canonical_url)
  end
end

.resolve_via_oembed(canonical_url) ⇒ Object



71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
# File 'lib/asciidoctor/beautify_uri/providers/spotify.rb', line 71

def self.resolve_via_oembed(canonical_url)
  uri = URI(OEMBED_ENDPOINT)
  uri.query = URI.encode_www_form(url: canonical_url)
  json = get_json(uri)
  return nil unless json && json['title']

  {
    title: json['title'],
    subtitle: nil,
    thumbnail_url: json['thumbnail_url'],
    thumbnail_width: json['thumbnail_width'],
    thumbnail_height: json['thumbnail_height'],
    target_url: canonical_url,
  }
end

.resolve_via_web_api(path, canonical_url, client_id, client_secret) ⇒ Object



87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
# File 'lib/asciidoctor/beautify_uri/providers/spotify.rb', line 87

def self.resolve_via_web_api(path, canonical_url, client_id, client_secret)
  token = fetch_access_token(client_id, client_secret)
  unless token
    Logging.debug('spotify Web API credentials rejected (token request failed) — falling back to unauthenticated oEmbed')
    return resolve_via_oembed(canonical_url)
  end

  json = get_json(URI("#{API_BASE}/#{api_path_for path}"), headers: { 'Authorization' => "Bearer #{token}" })
  unless json && json['name']
    Logging.debug(%(spotify Web API lookup for "#{path}" failed or returned no name — falling back to unauthenticated oEmbed))
    return resolve_via_oembed(canonical_url)
  end

  image = thumbnail_image_for(json)
  {
    title: json['name'],
    subtitle: subtitle_for(json),
    thumbnail_url: image && image['url'],
    thumbnail_width: image && image['width'],
    thumbnail_height: image && image['height'],
    target_url: canonical_url,
  }
end

.subtitle_for(json) ⇒ Object



119
120
121
122
123
124
125
126
127
# File 'lib/asciidoctor/beautify_uri/providers/spotify.rb', line 119

def self.subtitle_for(json)
  if json['artists'].is_a?(Array) && !json['artists'].empty?
    json['artists'].map { |a| a['name'] }.compact.join(', ')
  elsif json.dig('show', 'name')
    json['show']['name']
  elsif json.dig('owner', 'display_name')
    "by #{json['owner']['display_name']}"
  end
end

.thumbnail_image_for(json) ⇒ Object



129
130
131
132
# File 'lib/asciidoctor/beautify_uri/providers/spotify.rb', line 129

def self.thumbnail_image_for(json)
  images = json.dig('album', 'images') || json['images']
  images&.first
end