关于 connector-spotify
connector-spotify 是基于 Spotify 官方 OpenAPI 生成的 Motoko 绑定,面向在 Internet Computer canister 中调用 Spotify Web API 的开发者。它覆盖目录、播放控制等 API,并要求调用方提供 OAuth bearer token。
主要功能
- 01Spotify Web API 的 Motoko 绑定
- 02覆盖目录与播放器操作
- 03调用方传入 OAuth bearer token
- 04默认使用非复制出站请求
- 05写入请求检查 HTTP 状态
适用场景
- 01搜索曲目、专辑与艺人
- 02读取当前播放信息并控制播放器
- 03在有相应授权时修改播放列表或曲库
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ caffeinelabs/ skills --skill connector-spotify安装后在 Claude Code 中输入/connector-spotify确认已加载。
详细介绍
使用 spotify-client 的 Spotify
基于 Motoko 的 Spotify Web API 绑定,由 Spotify 官方 OpenAPI 规范生成:15 个 API 模块,270 个操作。此软件包以 icp-cli 模式(管理 canister 接口使用 mo:ic/Types)构建,因此会将 ic 作为依赖项,并在 [toolchain] 中固定 PocketIC 版本。
后端
读取目录数据并驱动播放器。令牌始终来自调用方——canister 从不持有 Spotify 客户端密钥:
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, "");
};
}
身份验证:canister 从不接触客户端密钥
每次调用都需要 OAuth 2.0 bearer 访问令牌,该令牌在链下签发并传入。以下两种流程最为重要:
- Client Credentials — 服务器到服务器,无用户参与。只能读取公开目录:
search、getTrack、getAnAlbum、getAnArtist、getNewReleases、getCategories、getAvailableMarkets、公开播放列表、公开节目。它无法访问任何/me/*端点、资料库或播放器。 - Authorization Code with PKCE — 面向用户。每个
/me/*调用、播放列表修改、资料库写入和播放器命令都需要此流程,并且各自受相应 scope 限制(user-read-playback-state、user-modify-playback-state、playlist-modify-public、user-library-read,等等)。令牌中缺少 scope 时会返回 403,而不是验证错误。
令牌在 一小时后过期,刷新操作也在链下进行。遇到 401 时,应视为“请客户端刷新令牌并重试”,而不是永久性失败;绝不要在 canister 中存储客户端密钥。
默认情况下,调用不会被复制执行
此软件包在 defaultConfig 中提供 is_replicated = ?false,135 个操作中有 91 个依赖此设置。对于 27 个 PUT 和 17 个 DELETE 操作,生成器已为每个请求固定为不复制执行,因为 IC 在这些操作中要求这样做——因此播放列表编辑和资料库保存从未面临风险。默认设置则涵盖其余操作:
- 7 个
POST,包括addToQueue和两个跳过端点。这些操作并非幂等;如果复制执行,就会将曲目加入队列约 13 次,并跳过约 13 首曲目; - 全部 84 个
GET对播放器而言都是非确定性的——当前播放内容包含timestamp和progress_ms,各节点返回的值不同——即使结果一致,所耗 cycles 也会增加约 13 倍。
你无需自行设置此标记;默认值是正确的。只有同时使用能够剔除易变字段的 transform 时,才应将其覆盖为 is_replicated = ?true。
从 0.2.x 升级——旧建议正好相反。 那份技能建议调用方在修改操作中保留 is_replicated = null,“因为共识很重要”,并展示了 let userCfg = { cfg with is_replicated = null }。现在继续沿用这种做法会造成实际危害:null 表示复制执行,因此每次 addToQueue 和跳过操作都会在每个副本上执行一次。删除任何此类覆盖,直接使用 defaultConfig 的默认设置。
响应中的所有字段都是可选的
Spotify 几乎不会将任何响应字段标记为必填,因此模型中的字段全部都是可选的:TrackObject.name : ?Text、.artists : ?[SimplifiedArtistObject]、CurrentlyPlayingContextObject.is_playing : ?Bool。使用 do ? 代码块逐层访问,而不要使用嵌套的 switch;并根据调用方的需求判断字段缺失意味着什么——对于令牌 scope 未覆盖的字段,Spotify 会将其省略。
使用 ID,而非 URI
端点参数接受 Spotify 的 base-62 ID(11dFghVXANMlKmJXsNCbNl),而非 URI(spotify:track:11dFghVXANMlKmJXsNCbNl),也不是 URL。用户粘贴 Spotify 链接时,应提取最后一个 / 之后、任何 ? 之前的部分。播放列表操作中的 uris 参数是例外——这些参数确实接受完整的 spotify:track:… URI。
开始播放
PlayerApi.startAUsersPlayback 将这四个字段作为标量接收——
context_uri : ?Text、uris : ?[Text]、position_ms : ?Int,而
transferAUsersPlayback 接收 play : ?Bool。在 uris(指定曲目)和
context_uri(专辑、艺人或播放列表)中选择一个,不能同时使用两者。
offset 是个例外:规范中它是一个自由格式对象,因此生成器会将其映射为
?Map<Text, Text>,并将每个值都序列化为字符串。因此
{"uri": "spotify:track:…"} 可以使用,而 Spotify 文档中的另一种形式
{"position": 5} 会被发送为 {"position": "5"},并遭到拒绝。通过 URI 而非索引指定上下文中的位置。
有一个操作无法使用
PlaylistsApi.uploadCustomPlaylistCover——规范将请求体声明为 image/jpeg(format: byte),但生成器始终只会发送 JSON 请求体,因此调用会发送 Content-Type: application/json,并将 base64 内容包在 JSON 引号中。Spotify 会拒绝该请求。这并非 0.3.0 才出现的问题:0.2.2 会将原始 Blob 包装在 JSON 中,线上传输结果同样有误。请勿提供自定义播放列表封面上传功能,也不要用 ic.http_request 手动实现;请在
caffeinelabs/skills-internal 中提出此问题。
除此之外,所有接口都正常——与某些连接器不同,这里没有存根 oneOf 转换器,没有会悄悄丢弃请求体的操作,也没有端点将非 JSON 响应体交给 JSON 解码器。
空闲播放器会报错,而不是返回“当前没有播放内容”
当播放未激活时,PlayerApi.getInformationAboutTheUsersCurrentPlayback 和
getTheUsersCurrentlyPlayingTrack 会返回状态码 204,且响应体为空。生成的代码会将所有 2xx 响应都视为 200 响应模式,并对其运行 JSON 解码器,因此空闲播放器会产生
Error.reject(… Failed to parse JSON …),而不是返回空值。请用 try/catch 包装这两个操作,并像 §Backend 示例那样,将拒绝视为“当前没有播放内容”。
这是代码生成方面的缺口,并非 Spotify 的特殊行为:规范为第一个操作声明了
'204': Playback not available or active(生成器会忽略无内容的 2xx 响应,也没有 ?T 返回类型可用于表示这类响应);而第二个操作中,Spotify 实际上会返回 204,但规范并未声明。若要为已声明的无内容响应建模,生成的签名会变为
?CurrentlyPlayingContextObject,因此应将此作为插件中的独立变更来处理。
现在每次写入操作都会报告状态
在 0.3.0 之前,返回 async* () 的 43 个操作——skip、pause、
addToQueue 以及所有资料库和播放列表修改操作——会完全丢弃 HTTP 响应,因此无法区分 401、403 或 429 与成功。现在,任何非 2xx 状态都会导致拒绝。请预期写入操作中的 try/catch 会实际触发:缺少 scope 会显示为 403,而之前这类情况看起来像是成功的空操作。
调用周期数与响应大小
将 cycles 留空(null):每次调用都会附带 IC 为该请求收取的费用;在出站调用免费的子网上,费用为零。?n 会附带恰好 n 个调用周期数:如果 n 低于费用,IC 会拒绝调用;如果容器持有的调用周期数少于 n,调用会发生 trap。
对于较大的页面——limit=50 的 search、曲目列表很长的 getAnAlbum,或返回超大对象的
getAudioAnalysis——应显式设置
max_response_bytes = ?2_000_000。
错误
非 2xx 响应和解码失败会 throw Error.reject(…)。客户端启用了 diagnostics 生成,因此消息格式为
HTTP <status> body[<n>B]=<first 100 chars>: <reason>——无需额外日志,就足以区分 401(令牌过期)、403(缺少 scope)和 404(ID 无效)。使用 try/catch 捕获错误,并通过 Error.message(err) 显示错误信息。