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
CODE_ENDPOINT =

https://www.spotifycodes.com — the same public-facing generator Spotify itself operates, backed by this same undocumented but long-stable image endpoint (confirmed live; the generator page exposes an identical format/background/color/size parameter grammar). Licensed for exactly this use under the Spotify Codes Terms & Conditions: "granted a non-exclusive license to use and display Spotify Codes for the purpose of sharing a piece of content from the Spotify Service." Two hard restrictions from those same terms this implementation must not violate: never modify the returned image, and never display other content merged into/over it — see uri:provider:path in DESIGN-asciidoctor-beautify-uri.adoc, "Inline Macro Syntax", for how that constrains rendering.

'https://scannables.scdn.co/uri/plain'

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.



212
213
214
215
# File 'lib/asciidoctor/beautify_uri/providers/spotify.rb', line 212

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

.code_url(path, format: 'svg', background: '000000', color: 'white', size: 640) ⇒ Object



73
74
75
76
77
78
79
80
# File 'lib/asciidoctor/beautify_uri/providers/spotify.rb', line 73

def self.code_url(path, format: 'svg', background: '000000', color: 'white', size: 640)
  return nil if path.nil? || path.empty?

  type, id = path.split('/', 2)
  return nil unless type && id && !id.empty?

  %(#{CODE_ENDPOINT}/#{format}/#{background}/#{color}/#{size}/spotify:#{type}:#{id})
end

.dark_hex?(hex) ⇒ Boolean

Returns:

  • (Boolean)


108
109
110
111
112
113
114
# File 'lib/asciidoctor/beautify_uri/providers/spotify.rb', line 108

def self.dark_hex?(hex)
  return true unless hex.is_a?(String) && hex.length >= 6

  r, g, b = [hex[0, 2], hex[2, 2], hex[4, 2]].map { |h| h.to_i(16) }
  # Standard relative-luminance perception weighting (ITU-R BT.601).
  ((0.299 * r) + (0.587 * g) + (0.114 * b)) < 128
end

.display_nameObject



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

def self.display_name = 'Spotify'

.fetch_access_token(client_id, client_secret) ⇒ Object



232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
# File 'lib/asciidoctor/beautify_uri/providers/spotify.rb', line 232

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

.fetch_code_tempfile(path, doc, **opts) ⇒ Object

PDF-only: unlike a thumbnail URL (deferred to real conversion time via pdf_card.rb/PdfConverter, which has a live Prawn document to call resolve_image_path on), the inline macro's asciidoctor-pdf formatted-text tag is built eagerly at macro-process time — see inline_macro.rb — with no converter instance yet in scope. So the Code image is fetched right here, the same live-on-every-build policy every other lookup in this file already follows, into a Tempfile the caller must keep referenced for the rest of the document's conversion (a Tempfile with no live Ruby reference can be garbage-collected, and finalized/unlinked, before Prawn ever reads it back off disk).



127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
# File 'lib/asciidoctor/beautify_uri/providers/spotify.rb', line 127

def self.fetch_code_tempfile(path, doc, **opts)
  unless doc.attr?('allow-uri-read')
    Logging.debug('code=true requires -a allow-uri-read (or attributes: {"allow-uri-read"=>""} via the API) to fetch a Spotify Code for PDF output')
    return nil
  end

  theme_background, theme_bar_color = pdf_theme_colors(doc)
  url = code_url(path, **{ background: theme_background, color: theme_bar_color }.merge(opts))
  return nil unless url

  uri = URI(url)
  http = Net::HTTP.new(uri.host, uri.port)
  http.use_ssl = true
  http.open_timeout = HTTP_TIMEOUT
  http.read_timeout = HTTP_TIMEOUT
  response = http.request(Net::HTTP::Get.new(uri))
  return nil unless response.is_a?(Net::HTTPSuccess)

  tempfile = Tempfile.new(['spotify-code', ".#{opts[:format] || 'svg'}"])
  tempfile.binmode
  tempfile.write(response.body)
  tempfile.close
  tempfile
rescue StandardError => e
  Logging.debug(%(spotify Code fetch for "#{path}" failed: #{e.message}))
  nil
end

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



250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
# File 'lib/asciidoctor/beautify_uri/providers/spotify.rb', line 250

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



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

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



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

def self.key = 'spotify'


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

def self.link_text = 'Play on Spotify'

.pdf_theme_colors(doc) ⇒ Object

Spotify Codes are drawn as bars on a plain background, no transparency — left at scannables.scdn.co's own defaults (black background, white bars) they'd sit as an opaque black rectangle on whatever PDF theme is in play, clashing with a light theme and looking arbitrary next to a dark one. Matching the page's own background_color and rounding the bar color to whichever of black or white reads closer to the theme's own body text color (the scannables API only accepts one of those two for code-color, no arbitrary hex) makes the Code read as part of the page rather than a foreign rectangle pasted onto it. ThemeLoader.load_theme is a class method with no live converter/document instance required — the same theme resolution PdfConverter#load_theme does internally, just callable this early, at macro-process time, before real PDF conversion (and its one live Prawn document) exists at all.



96
97
98
99
100
101
102
103
104
105
106
# File 'lib/asciidoctor/beautify_uri/providers/spotify.rb', line 96

def self.pdf_theme_colors(doc)
  theme_name = doc.attr('pdf-theme')
  themesdir = (doc.attr 'pdf-themesdir')&.sub '{docdir}', (doc.attr 'docdir')
  theme = doc.options[:pdf_theme] || ::Asciidoctor::PDF::ThemeLoader.load_theme(theme_name, themesdir)
  background = (theme.page_background_color || 'FFFFFF').to_s.delete('#')
  font_color = (theme.base_font_color || '333333').to_s.delete('#')
  [background, dark_hex?(font_color) ? 'black' : 'white']
rescue StandardError => e
  Logging.debug(%(spotify Code: could not resolve the PDF theme's colors, using scannables.scdn.co's own defaults: #{e.message}))
  %w[000000 white]
end

.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.



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

def self.pdf_thumbnail_radius_pt = 4

.resolve(path, doc) ⇒ Object



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

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



169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
# File 'lib/asciidoctor/beautify_uri/providers/spotify.rb', line 169

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



185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
# File 'lib/asciidoctor/beautify_uri/providers/spotify.rb', line 185

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



217
218
219
220
221
222
223
224
225
# File 'lib/asciidoctor/beautify_uri/providers/spotify.rb', line 217

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



227
228
229
230
# File 'lib/asciidoctor/beautify_uri/providers/spotify.rb', line 227

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