Module: Asciidoctor::BeautifyUri::Providers::Spotify
- Defined in:
- lib/asciidoctor/beautify_uri/providers/spotify.rb
Overview
uri::spotify:track/
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
-
.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.
- .code_url(path, format: 'svg', background: '000000', color: 'white', size: 640) ⇒ Object
- .dark_hex?(hex) ⇒ Boolean
- .display_name ⇒ Object
- .fetch_access_token(client_id, client_secret) ⇒ Object
-
.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.
- .get_json(uri, headers: {}) ⇒ Object
- .icon_block ⇒ Object
- .key ⇒ Object
- .link_text ⇒ Object
-
.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.
-
.pdf_thumbnail_radius_pt ⇒ Object
https://developer.spotify.com/documentation/design: "Artwork corners must be rounded to create optical blending with nearby UI elements.
- .resolve(path, doc) ⇒ Object
- .resolve_via_oembed(canonical_url) ⇒ Object
- .resolve_via_web_api(path, canonical_url, client_id, client_secret) ⇒ Object
- .subtitle_for(json) ⇒ Object
- .thumbnail_image_for(json) ⇒ Object
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
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_name ⇒ Object
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.})) 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, = pdf_theme_colors(doc) url = code_url(path, **{ background: theme_background, 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.})) 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.})) nil end |
.icon_block ⇒ Object
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 |
.key ⇒ Object
35 |
# File 'lib/asciidoctor/beautify_uri/providers/spotify.rb', line 35 def self.key = 'spotify' |
.link_text ⇒ Object
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.[: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.})) %w[000000 white] end |
.pdf_thumbnail_radius_pt ⇒ Object
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 (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.(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 (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 (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 |