Connector Spotify

作者 caffeinelabs362f51ae96f0無授權條款2 個星標收錄於 2026年10月8日更新於 2026年10月8日儲存庫今天更新

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.

AI 產生的概覽

指導 Motoko canister 開發者正確使用 spotify-client 套件呼叫 Spotify Web API。

功能
此技能是一份參考指南,用於建置讀取 Spotify 目錄資料或控制使用者 Spotify 帳戶的 Caffeine canister。它說明 spotify-client Motoko 套件,涵蓋 OAuth 2.0 bearer 權杖處理、非複製型對外 HTTPS 呼叫、可選回應欄位、ID 與 URI 參數的差異、播放啟動語意、cycles 與回應大小設定以及錯誤處理。它也列出已知限制,例如不支援自訂播放清單封面圖片上傳,以及閒置播放器會回傳拒絕而非空值。
適用情境
當任務涉及 Spotify 時使用,例如曲目、藝人、專輯、播放清單、搜尋、新發行、播放狀態、播放器控制、收藏庫、播客或有聲書。它應在撰寫任何觸及 Spotify 端點的程式碼之前載入。
執行需求
需要 Motoko spotify-client mops 套件(版本約 0.3.0)和 ic 相依項目,以及鏈下產生的 OAuth 2.0 bearer 存取權杖。需要存取 Spotify Web API 的對外 HTTPS 網路連線。它不附帶指令碼,僅為說明文件。

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:

motoko
import { getTrack } "mo:spotify-client/Apis/TracksApi";import { getInformationAboutTheUsersCurrentPlayback; skipUsersPlaybackToNextTrack } "mo:spotify-client/Apis/PlayerApi";import { type Config; defaultConfig } "mo:spotify-client/Config";
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).

來源與署名

來源:caffeinelabs/skills位於skills/connector-spotify提交362f51a

授權條款: 無授權條款

內容歸原作者所有。SourceWeft 從公開儲存庫中收錄這些內容。

檢舉或申請下架