connector-spotify

connector-spotify

MANDATORY recipe for every Caffeine build that reads Spotify catalog data or drives a user's Spotify account from a canister. The supported path is the `spotify-client` mops package (Spotify Web API) over outbound HTTPS with an OAuth 2.0 bearer token minted off-chain. Hand-rolling `ic.http_request` calls to `api.spotify.com` is a FORBIDDEN anti-pattern — it bypasses the non-replicated-outcall safeguard (player state and `progress_ms` differ per node and fail consensus), the generated JSON decoding, and the bearer handling. Load this skill whenever the user, spec, or any prior task mentions Spotify, a song, track, artist, album, playlist, music search, new releases, genres, markets, "what's playing", recently played, the queue, player controls (play / pause / skip / shuffle / repeat), a saved library, podcasts (shows or episodes), chapters or audiobooks — and BEFORE writing any code that touches a Spotify endpoint.

2Star
1Fork
更新于 2026/9/25
请求的译文尚未完成,当前显示原始英文。
SKILL.md
只读
名称
connector-spotify
描述

MANDATORY recipe for every Caffeine build that reads Spotify catalog data or drives a user's Spotify account from a canister. The supported path is the `spotify-client` mops package (Spotify Web API) over outbound HTTPS with an OAuth 2.0 bearer token minted off-chain. Hand-rolling `ic.http_request` calls to `api.spotify.com` is a FORBIDDEN anti-pattern — it bypasses the non-replicated-outcall safeguard (player state and `progress_ms` differ per node and fail consensus), the generated JSON decoding, and the bearer handling. Load this skill whenever the user, spec, or any prior task mentions Spotify, a song, track, artist, album, playlist, music search, new releases, genres, markets, "what's playing", recently played, the queue, player controls (play / pause / skip / shuffle / repeat), a saved library, podcasts (shows or episodes), chapters or audiobooks — and BEFORE writing any code that touches a Spotify endpoint.

版本
0.3.0

Spotify with spotify-client

Motoko bindings for the
Spotify Web API,
generated from Spotify's official OpenAPI spec: 15 API modules, 270
operations
. The package is built in icp-cli mode (mo:ic/Types for the
management-canister interface), so it pulls ic as a dependency and pins
PocketIC in [toolchain].

Backend

Reading catalog data and driving the player. The token always comes from the
caller — the canister never holds a Spotify client secret:

import { getTrack } "mo:spotify-client/Apis/TracksApi";
import { getInformationAboutTheUsersCurrentPlayback; skipUsersPlaybackToNextTrack } "mo:spotify-client/Apis/PlayerApi";
import { type Config; defaultConfig } "mo:spotify-client/Config";

persistent actor {
  func config(accessToken : Text) : Config = { defaultConfig with auth = ?#bearer accessToken };

  // Catalog read — a client-credentials token suffices. `market = ""` omits the
  // parameter; empty strings and zeroes are how optional query params are
  // dropped, there is no `?Text` to leave null.
  public func trackName(accessToken : Text, id : Text) : async ?Text {
    let track = await* getTrack(config accessToken, id, "");
    track.name;
  };

  // User read — needs a user token with `user-read-playback-state`. An idle
  // player answers 204 with an empty body, which the generated decoder cannot
  // parse, so it rejects; treat that as "nothing playing", not as an error.
  public func nowPlaying(accessToken : Text) : async ?Bool {
    try {
      let state = await* getInformationAboutTheUsersCurrentPlayback(config accessToken, "", "");
      state.is_playing;
    } catch (_err) {
      null;
    };
  };

  // User write — needs `user-modify-playback-state`. Returns `()`, and since
  // 0.3.0 a non-2xx status rejects, so a missing scope or an expired token
  // surfaces here instead of looking like success.
  public func skip(accessToken : Text) : async () {
    await* skipUsersPlaybackToNextTrack(config accessToken, "");
  };
}

Auth: the canister never sees a client secret

Every call needs an OAuth 2.0 bearer access token, minted off-chain and
passed in. Two flows matter:

  1. Client Credentials — server-to-server, no user. Reads public catalog
    only: search, getTrack, getAnAlbum, getAnArtist, getNewReleases,
    getCategories, getAvailableMarkets, public playlists, public shows. It
    cannot touch any /me/* endpoint, the library, or the player.
  2. Authorization Code with PKCE — user-facing. Required for every /me/*
    call, playlist mutation, library write and player command, each gated by its
    own scope (user-read-playback-state, user-modify-playback-state,
    playlist-modify-public, user-library-read, …). Scopes absent from the
    token surface as 403, not as a validation error.

Tokens expire after one hour and refresh is off-chain too. Treat a 401 as
"ask the client to refresh and retry", never as a permanent failure, and never
store a client secret in the canister.

Calls are non-replicated by default

The package ships is_replicated = ?false in defaultConfig, and 91 of the
135 operations depend on it
. The generator already pins non-replication per
request for the 27 PUT and 17 DELETE operations, because the IC requires it
there — so playlist edits and library saves were never at risk. The default is
what covers the rest:

  • the 7 POSTs, which include addToQueue and both skip endpoints. These
    are not idempotent, so replicated they would queue a track ~13 times and skip
    ~13 tracks;
  • all 84 GETs, which are non-deterministic for the player —
    currently-playing carries timestamp and progress_ms, differing per node —
    and would cost ~13x the cycles even where they agree.

You don't set the flag yourself; the default is correct. Override with
is_replicated = ?true only together with a transform that strips the
volatile fields.

Upgrading from 0.2.x — the old advice was backwards. That skill told callers
to keep is_replicated = null for mutations "because consensus matters", and
showed let userCfg = { cfg with is_replicated = null }. Carrying that forward
is now actively harmful: null means replicated, so every addToQueue and
skip would fire once per replica. Delete any such override and take
defaultConfig as it comes.

Everything in a response is optional

Spotify marks almost no response field required, so the models are all-optional:
TrackObject.name : ?Text, .artists : ?[SimplifiedArtistObject],
CurrentlyPlayingContextObject.is_playing : ?Bool. Reach through with a do ?
block rather than nested switches, and decide what absence means for your
caller — Spotify omits fields your token's scopes don't cover.

IDs, not URIs

Endpoint parameters take Spotify's base-62 IDs
(11dFghVXANMlKmJXsNCbNl), not URIs (spotify:track:11dFghVXANMlKmJXsNCbNl)
and not URLs. When a user pastes a Spotify link, extract the segment after the
last / and before any ?. The uris parameters on playlist operations are
the exception — those do take full spotify:track:… URIs.

Starting playback

PlayerApi.startAUsersPlayback takes the four fields as scalars —
context_uri : ?Text, uris : ?[Text], position_ms : ?Int — and
transferAUsersPlayback takes play : ?Bool. Pick one of uris (explicit
tracks) or context_uri (an album, artist or playlist), never both.

offset is the exception: it is a free-form object in the spec, so the
generator maps it to ?Map<Text, Text> and serialises every value as a
string
. {"uri": "spotify:track:…"} therefore works, while Spotify's other
documented form {"position": 5} goes out as {"position": "5"} and is
rejected. Offset into a context by URI, not by index.

One operation does not work

PlaylistsApi.uploadCustomPlaylistCover — the spec declares the body as
image/jpeg (format: byte), but the generator only ever emits JSON request
bodies, so the call sends Content-Type: application/json with the base64
wrapped in JSON quotes. Spotify rejects it. This is not new in 0.3.0: 0.2.2
JSON-wrapped the raw Blob instead, equally wrong on the wire. Do not offer
custom playlist cover upload, and do not hand-roll it with ic.http_request;
raise it on
caffeinelabs/skills-internal.

Everything else in the surface is sound — unlike some connectors, there are no
stubbed oneOf converters, no operation silently dropping its request body, and
no endpoint returning a non-JSON body through the JSON decoder.

The idle player rejects instead of returning "nothing playing"

PlayerApi.getInformationAboutTheUsersCurrentPlayback and
getTheUsersCurrentlyPlayingTrack answer 204 with an empty body when
playback is not active. The generated code treats every 2xx as the 200 schema
and runs the JSON decoder over it, so an idle player produces
Error.reject(… Failed to parse JSON …) rather than an absent value. Wrap both
in try/catch and read a rejection as "nothing playing", as the §Backend
sample does.

This is a codegen gap, not a Spotify quirk: the spec declares
'204': Playback not available or active for the first operation (the generator
ignores no-content 2xx responses and has no ?T return shape for them), while
for the second Spotify returns 204 in practice without declaring it at all.
Modelling declared no-content responses would change the generated signature to
?CurrentlyPlayingContextObject, so it belongs in the plugin as its own
change.

Every write now reports its status

Until 0.3.0 the 43 operations returning async* () — skip, pause,
addToQueue, every library and playlist mutation — discarded the HTTP response
entirely, so a 401, 403 or 429 was indistinguishable from success. They now
reject on any non-2xx status. Expect try/catch around writes to actually
fire: a missing scope surfaces as 403 where it previously looked like a
successful no-op.

Cycles and response sizes

defaultConfig.cycles = 30_000_000_000 suits a typical single-object read.
Large pages need more: search with limit=50, getAnAlbum on a long
tracklist, or getAudioAnalysis (which returns a very large object) want
cycles = 100_000_000_000 and an explicit
max_response_bytes = ?2_000_000.

Errors

Non-2xx responses and decode failures throw Error.reject(…). The client is
generated with diagnostics, so the message is
HTTP <status> body[<n>B]=<first 100 chars>: <reason> — enough to separate a
401 (expired token) from a 403 (missing scope) from a 404 (bad ID) without extra
logging. Catch with try/catch and surface Error.message(err).