@mulmoclaude/spotify-plugin 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Preview.vue.d.ts +15 -0
- package/dist/View.vue.d.ts +3 -0
- package/dist/client.d.ts +49 -0
- package/dist/definition-CfBmxEFr.js +4388 -0
- package/dist/definition-CfBmxEFr.js.map +1 -0
- package/dist/definition.d.ts +76 -0
- package/dist/index.d.ts +141 -0
- package/dist/index.js +1364 -0
- package/dist/index.js.map +1 -0
- package/dist/lang/en.d.ts +55 -0
- package/dist/lang/index.d.ts +107 -0
- package/dist/lang/ja.d.ts +55 -0
- package/dist/listening.d.ts +29 -0
- package/dist/normalize.d.ts +18 -0
- package/dist/oauth.d.ts +36 -0
- package/dist/playback.d.ts +30 -0
- package/dist/profile.d.ts +32 -0
- package/dist/schemas.d.ts +135 -0
- package/dist/search.d.ts +19 -0
- package/dist/searchSummary.d.ts +20 -0
- package/dist/style.css +367 -0
- package/dist/time.d.ts +2 -0
- package/dist/tokens.d.ts +19 -0
- package/dist/types.d.ts +170 -0
- package/dist/vue.d.ts +80 -0
- package/dist/vue.js +867 -0
- package/dist/vue.js.map +1 -0
- package/package.json +43 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1364 @@
|
|
|
1
|
+
import { i as TokensSchema, n as ClientConfigSchema, r as DispatchArgsSchema, t as TOOL_DEFINITION } from "./definition-CfBmxEFr.js";
|
|
2
|
+
//#region ../../../node_modules/gui-chat-protocol/dist/index.js
|
|
3
|
+
function definePlugin(setup) {
|
|
4
|
+
return setup;
|
|
5
|
+
}
|
|
6
|
+
//#endregion
|
|
7
|
+
//#region src/oauth.ts
|
|
8
|
+
/** Maximum age before a pending authorization is considered stale.
|
|
9
|
+
* Spotify's authorize page typically redirects back within a
|
|
10
|
+
* minute; 10 minutes covers slow users without leaking entries
|
|
11
|
+
* forever. The runtime is sandboxed (no `runtime.now`), so this
|
|
12
|
+
* uses `Date.now()` directly — pure number, not external state. */
|
|
13
|
+
var PENDING_TTL_MS = 600 * 1e3;
|
|
14
|
+
var _pendingAuthorizations = /* @__PURE__ */ new Map();
|
|
15
|
+
/** Generate 32 bytes of random entropy as a base64url string.
|
|
16
|
+
* Used both for `code_verifier` (PKCE) and `state` (CSRF). */
|
|
17
|
+
function generateRandomToken() {
|
|
18
|
+
const bytes = new Uint8Array(32);
|
|
19
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
20
|
+
return base64UrlEncode(bytes);
|
|
21
|
+
}
|
|
22
|
+
/** Derive `code_challenge` from `code_verifier`: SHA-256 then
|
|
23
|
+
* base64url. Spotify's authorize URL carries this; the token
|
|
24
|
+
* endpoint receives the verifier and re-derives. */
|
|
25
|
+
async function deriveCodeChallenge(codeVerifier) {
|
|
26
|
+
const buffer = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(codeVerifier));
|
|
27
|
+
return base64UrlEncode(new Uint8Array(buffer));
|
|
28
|
+
}
|
|
29
|
+
/** Encode bytes as RFC 4648 base64url (no padding). The standard
|
|
30
|
+
* `btoa` produces base64; we replace the URL-unsafe characters and
|
|
31
|
+
* strip padding to match what Spotify's authorize / token
|
|
32
|
+
* endpoints expect. */
|
|
33
|
+
function base64UrlEncode(bytes) {
|
|
34
|
+
let binary = "";
|
|
35
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
36
|
+
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
|
|
37
|
+
}
|
|
38
|
+
/** Register a fresh pending authorization. Returns the `state` the
|
|
39
|
+
* caller embeds on the authorize URL. Sweeps stale entries on
|
|
40
|
+
* every call so the map can't grow unbounded across abandoned
|
|
41
|
+
* attempts. */
|
|
42
|
+
function registerPendingAuthorization(codeVerifier, redirectUri, now = /* @__PURE__ */ new Date()) {
|
|
43
|
+
sweepStaleAuthorizations(now);
|
|
44
|
+
const state = generateRandomToken();
|
|
45
|
+
_pendingAuthorizations.set(state, {
|
|
46
|
+
codeVerifier,
|
|
47
|
+
redirectUri,
|
|
48
|
+
createdAtMs: now.getTime()
|
|
49
|
+
});
|
|
50
|
+
return state;
|
|
51
|
+
}
|
|
52
|
+
/** Look up + consume a pending authorization by `state`. Single-
|
|
53
|
+
* use: a successful lookup deletes the record so the same state
|
|
54
|
+
* can't be replayed. Returns null when the state is unknown
|
|
55
|
+
* (CSRF / stale / replayed). */
|
|
56
|
+
function consumePendingAuthorization(state, now = /* @__PURE__ */ new Date()) {
|
|
57
|
+
sweepStaleAuthorizations(now);
|
|
58
|
+
const entry = _pendingAuthorizations.get(state);
|
|
59
|
+
if (!entry) return null;
|
|
60
|
+
_pendingAuthorizations.delete(state);
|
|
61
|
+
return entry;
|
|
62
|
+
}
|
|
63
|
+
function sweepStaleAuthorizations(now) {
|
|
64
|
+
const cutoff = now.getTime() - PENDING_TTL_MS;
|
|
65
|
+
for (const [state, entry] of _pendingAuthorizations) if (entry.createdAtMs < cutoff) _pendingAuthorizations.delete(state);
|
|
66
|
+
}
|
|
67
|
+
/** Build the Spotify authorize URL. Pure — no side effects, no
|
|
68
|
+
* randomness; `state` and `codeChallenge` are passed in so the
|
|
69
|
+
* caller controls them (for replay registration and tests). */
|
|
70
|
+
function buildAuthorizeUrl(params) {
|
|
71
|
+
return `https://accounts.spotify.com/authorize?${new URLSearchParams({
|
|
72
|
+
response_type: "code",
|
|
73
|
+
client_id: params.clientId,
|
|
74
|
+
redirect_uri: params.redirectUri,
|
|
75
|
+
scope: params.scopes.join(" "),
|
|
76
|
+
state: params.state,
|
|
77
|
+
code_challenge_method: "S256",
|
|
78
|
+
code_challenge: params.codeChallenge
|
|
79
|
+
}).toString()}`;
|
|
80
|
+
}
|
|
81
|
+
//#endregion
|
|
82
|
+
//#region src/time.ts
|
|
83
|
+
var ONE_SECOND_MS = 1e3;
|
|
84
|
+
60 * ONE_SECOND_MS;
|
|
85
|
+
//#endregion
|
|
86
|
+
//#region src/tokens.ts
|
|
87
|
+
var TOKENS_FILE = "tokens.json";
|
|
88
|
+
var CLIENT_CONFIG_FILE = "client.json";
|
|
89
|
+
/** Read persisted tokens. Returns null on absent / malformed (=
|
|
90
|
+
* caller treats as "not_connected" and walks the user back to the
|
|
91
|
+
* connect button). Throws only on the read I/O itself. */
|
|
92
|
+
async function readTokens(files) {
|
|
93
|
+
if (!await files.exists(TOKENS_FILE)) return null;
|
|
94
|
+
try {
|
|
95
|
+
const raw = await files.read(TOKENS_FILE);
|
|
96
|
+
const parsed = TokensSchema.safeParse(JSON.parse(raw));
|
|
97
|
+
return parsed.success ? parsed.data : null;
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/** Write the full token record. */
|
|
103
|
+
async function writeTokens(files, tokens) {
|
|
104
|
+
await files.write(TOKENS_FILE, JSON.stringify(tokens, null, 2));
|
|
105
|
+
}
|
|
106
|
+
/** Read the user-provided Client ID. Returns null when the file is
|
|
107
|
+
* absent / malformed (caller treats as "client_id_missing" and
|
|
108
|
+
* surfaces the setup guide). */
|
|
109
|
+
async function readClientConfig(files) {
|
|
110
|
+
if (!await files.exists(CLIENT_CONFIG_FILE)) return null;
|
|
111
|
+
try {
|
|
112
|
+
const raw = await files.read(CLIENT_CONFIG_FILE);
|
|
113
|
+
const parsed = ClientConfigSchema.safeParse(JSON.parse(raw));
|
|
114
|
+
return parsed.success ? parsed.data : null;
|
|
115
|
+
} catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/** Write the Client ID. The View's "Configure" form posts here
|
|
120
|
+
* via `runtime.dispatch({ kind: "configure", clientId })` (PR 2). */
|
|
121
|
+
async function writeClientConfig(files, config) {
|
|
122
|
+
await files.write(CLIENT_CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
123
|
+
}
|
|
124
|
+
/** Apply a refresh response to the persisted tokens, preserving the
|
|
125
|
+
* prior `refreshToken` when Spotify omits a fresh one (the common
|
|
126
|
+
* case). Pure — caller persists. */
|
|
127
|
+
function mergeRefreshResponse(prior, response, now = /* @__PURE__ */ new Date()) {
|
|
128
|
+
return {
|
|
129
|
+
accessToken: response.accessToken,
|
|
130
|
+
refreshToken: response.refreshToken ?? prior.refreshToken,
|
|
131
|
+
expiresAt: new Date(now.getTime() + response.expiresInSec * ONE_SECOND_MS).toISOString(),
|
|
132
|
+
scopes: response.scopes !== void 0 ? [...response.scopes] : prior.scopes
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
//#endregion
|
|
136
|
+
//#region src/client.ts
|
|
137
|
+
var SPOTIFY_API_BASE = "https://api.spotify.com";
|
|
138
|
+
var SPOTIFY_TOKEN_URL$1 = "https://accounts.spotify.com/api/token";
|
|
139
|
+
var SPOTIFY_API_HOST = "api.spotify.com";
|
|
140
|
+
var SPOTIFY_TOKEN_HOST$1 = "accounts.spotify.com";
|
|
141
|
+
var FETCH_TIMEOUT_MS = 15 * ONE_SECOND_MS;
|
|
142
|
+
/** Treat tokens within this window of expiry as already expired so
|
|
143
|
+
* a request that races the boundary refreshes proactively instead
|
|
144
|
+
* of waiting for the 401. */
|
|
145
|
+
var EXPIRY_LEEWAY_MS = 30 * ONE_SECOND_MS;
|
|
146
|
+
var RETRY_AFTER_FALLBACK_SEC = 60;
|
|
147
|
+
/** Make an authenticated Spotify API call. Path is relative to
|
|
148
|
+
* `https://api.spotify.com` (e.g. `/v1/me/player/recently-played`). */
|
|
149
|
+
async function spotifyApi(runtime, clientId, initialTokens, method, apiPath, init = {}, now = () => /* @__PURE__ */ new Date()) {
|
|
150
|
+
let tokens = initialTokens;
|
|
151
|
+
if (needsProactiveRefresh(tokens, now())) {
|
|
152
|
+
const refreshed = await refreshTokens(runtime, clientId, tokens, now);
|
|
153
|
+
if (!refreshed.ok) return {
|
|
154
|
+
ok: false,
|
|
155
|
+
error: refreshed.error
|
|
156
|
+
};
|
|
157
|
+
tokens = refreshed.tokens;
|
|
158
|
+
}
|
|
159
|
+
const firstAttempt = await callOnce(runtime, method, apiPath, init, tokens);
|
|
160
|
+
if (firstAttempt.ok || firstAttempt.error.kind !== "auth_expired") return firstAttempt;
|
|
161
|
+
const refreshed = await refreshTokens(runtime, clientId, tokens, now);
|
|
162
|
+
if (!refreshed.ok) return {
|
|
163
|
+
ok: false,
|
|
164
|
+
error: refreshed.error
|
|
165
|
+
};
|
|
166
|
+
return callOnce(runtime, method, apiPath, init, refreshed.tokens);
|
|
167
|
+
}
|
|
168
|
+
function needsProactiveRefresh(tokens, now) {
|
|
169
|
+
const expiresAtMs = Date.parse(tokens.expiresAt);
|
|
170
|
+
if (Number.isNaN(expiresAtMs)) return true;
|
|
171
|
+
return expiresAtMs - now.getTime() <= EXPIRY_LEEWAY_MS;
|
|
172
|
+
}
|
|
173
|
+
async function callOnce(runtime, method, apiPath, init, tokens) {
|
|
174
|
+
let response;
|
|
175
|
+
try {
|
|
176
|
+
response = await runtime.fetch(`${SPOTIFY_API_BASE}${apiPath}`, {
|
|
177
|
+
method,
|
|
178
|
+
headers: {
|
|
179
|
+
Authorization: `Bearer ${tokens.accessToken}`,
|
|
180
|
+
...init.body !== void 0 ? { "Content-Type": "application/json" } : {}
|
|
181
|
+
},
|
|
182
|
+
body: init.body !== void 0 ? JSON.stringify(init.body) : void 0,
|
|
183
|
+
timeoutMs: FETCH_TIMEOUT_MS,
|
|
184
|
+
allowedHosts: [SPOTIFY_API_HOST]
|
|
185
|
+
});
|
|
186
|
+
} catch (err) {
|
|
187
|
+
return {
|
|
188
|
+
ok: false,
|
|
189
|
+
error: {
|
|
190
|
+
kind: "spotify_api_error",
|
|
191
|
+
status: 0,
|
|
192
|
+
body: errorMessage$1(err)
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
if (response.status === 401) return {
|
|
197
|
+
ok: false,
|
|
198
|
+
error: {
|
|
199
|
+
kind: "auth_expired",
|
|
200
|
+
detail: "Spotify returned 401"
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
if (response.status === 429) return {
|
|
204
|
+
ok: false,
|
|
205
|
+
error: {
|
|
206
|
+
kind: "rate_limited",
|
|
207
|
+
retryAfterSec: parseRetryAfterSec(response.headers.get("Retry-After"))
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
if (!response.ok) {
|
|
211
|
+
const body = await response.text().catch(() => "");
|
|
212
|
+
return {
|
|
213
|
+
ok: false,
|
|
214
|
+
error: {
|
|
215
|
+
kind: "spotify_api_error",
|
|
216
|
+
status: response.status,
|
|
217
|
+
body: body.slice(0, 500)
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
if (response.status === 204) return {
|
|
222
|
+
ok: true,
|
|
223
|
+
data: null
|
|
224
|
+
};
|
|
225
|
+
try {
|
|
226
|
+
return {
|
|
227
|
+
ok: true,
|
|
228
|
+
data: await response.json()
|
|
229
|
+
};
|
|
230
|
+
} catch (err) {
|
|
231
|
+
return {
|
|
232
|
+
ok: false,
|
|
233
|
+
error: {
|
|
234
|
+
kind: "spotify_api_error",
|
|
235
|
+
status: response.status,
|
|
236
|
+
body: errorMessage$1(err)
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
/** Refresh the access token using the persisted refreshToken and
|
|
242
|
+
* persist the merged result. */
|
|
243
|
+
async function refreshTokens(runtime, clientId, tokens, now) {
|
|
244
|
+
let response;
|
|
245
|
+
try {
|
|
246
|
+
response = await runtime.fetch(SPOTIFY_TOKEN_URL$1, {
|
|
247
|
+
method: "POST",
|
|
248
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
249
|
+
body: new URLSearchParams({
|
|
250
|
+
grant_type: "refresh_token",
|
|
251
|
+
refresh_token: tokens.refreshToken,
|
|
252
|
+
client_id: clientId
|
|
253
|
+
}).toString(),
|
|
254
|
+
timeoutMs: FETCH_TIMEOUT_MS,
|
|
255
|
+
allowedHosts: [SPOTIFY_TOKEN_HOST$1]
|
|
256
|
+
});
|
|
257
|
+
} catch (err) {
|
|
258
|
+
return {
|
|
259
|
+
ok: false,
|
|
260
|
+
error: {
|
|
261
|
+
kind: "transient_error",
|
|
262
|
+
detail: `refresh fetch failed: ${errorMessage$1(err)}`
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
if (!response.ok) {
|
|
267
|
+
const body = await response.text().catch(() => "");
|
|
268
|
+
runtime.log.warn("refresh failed", {
|
|
269
|
+
status: response.status,
|
|
270
|
+
body: body.slice(0, 200)
|
|
271
|
+
});
|
|
272
|
+
const status = response.status;
|
|
273
|
+
return {
|
|
274
|
+
ok: false,
|
|
275
|
+
error: {
|
|
276
|
+
kind: status >= 500 || status === 408 || status === 429 ? "transient_error" : "auth_expired",
|
|
277
|
+
detail: `refresh returned ${status}`
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
let parsed;
|
|
282
|
+
try {
|
|
283
|
+
parsed = await response.json();
|
|
284
|
+
} catch (err) {
|
|
285
|
+
return {
|
|
286
|
+
ok: false,
|
|
287
|
+
error: {
|
|
288
|
+
kind: "transient_error",
|
|
289
|
+
detail: `refresh response parse failed: ${errorMessage$1(err)}`
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
const refreshFields = parseRefreshResponse(parsed);
|
|
294
|
+
if (!refreshFields) return {
|
|
295
|
+
ok: false,
|
|
296
|
+
error: {
|
|
297
|
+
kind: "auth_expired",
|
|
298
|
+
detail: "refresh response missing access_token / expires_in"
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
const merged = mergeRefreshResponse(tokens, refreshFields, now());
|
|
302
|
+
await writeTokens(runtime.files.config, merged);
|
|
303
|
+
return {
|
|
304
|
+
ok: true,
|
|
305
|
+
tokens: merged
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
function parseRefreshResponse(raw) {
|
|
309
|
+
if (typeof raw.access_token !== "string" || raw.access_token.length === 0) return null;
|
|
310
|
+
if (typeof raw.expires_in !== "number" || !Number.isFinite(raw.expires_in)) return null;
|
|
311
|
+
return {
|
|
312
|
+
accessToken: raw.access_token,
|
|
313
|
+
refreshToken: typeof raw.refresh_token === "string" && raw.refresh_token.length > 0 ? raw.refresh_token : void 0,
|
|
314
|
+
expiresInSec: raw.expires_in,
|
|
315
|
+
scopes: typeof raw.scope === "string" ? raw.scope.split(" ").filter(Boolean) : void 0
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
/** Parse a `Retry-After` header. Spotify normally returns delta-
|
|
319
|
+
* seconds (an integer) but the RFC also allows HTTP-date format.
|
|
320
|
+
* Anything non-finite or non-positive collapses to a safe 60s
|
|
321
|
+
* fallback so callers never propagate `NaN` (Codex review on
|
|
322
|
+
* PR #1164 caught this). */
|
|
323
|
+
function parseRetryAfterSec(headerValue) {
|
|
324
|
+
if (headerValue === null) return RETRY_AFTER_FALLBACK_SEC;
|
|
325
|
+
const trimmed = headerValue.trim();
|
|
326
|
+
if (trimmed === "") return RETRY_AFTER_FALLBACK_SEC;
|
|
327
|
+
const asInt = Number.parseInt(trimmed, 10);
|
|
328
|
+
if (Number.isFinite(asInt) && asInt > 0 && String(asInt) === trimmed) return asInt;
|
|
329
|
+
const asDateMs = Date.parse(trimmed);
|
|
330
|
+
if (Number.isFinite(asDateMs)) {
|
|
331
|
+
const deltaSec = Math.ceil((asDateMs - Date.now()) / ONE_SECOND_MS);
|
|
332
|
+
if (deltaSec > 0) return deltaSec;
|
|
333
|
+
}
|
|
334
|
+
return RETRY_AFTER_FALLBACK_SEC;
|
|
335
|
+
}
|
|
336
|
+
function errorMessage$1(err) {
|
|
337
|
+
return err instanceof Error ? err.message : String(err);
|
|
338
|
+
}
|
|
339
|
+
//#endregion
|
|
340
|
+
//#region src/normalize.ts
|
|
341
|
+
var isRecord = (value) => typeof value === "object" && value !== null;
|
|
342
|
+
function smallestImageUrl(images) {
|
|
343
|
+
if (!Array.isArray(images) || images.length === 0) return void 0;
|
|
344
|
+
for (let i = images.length - 1; i >= 0; i -= 1) {
|
|
345
|
+
const candidate = images[i];
|
|
346
|
+
if (typeof candidate?.url === "string" && candidate.url.length > 0) return candidate.url;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
function spotifyUrl(externalUrls) {
|
|
350
|
+
if (!isRecord(externalUrls)) return void 0;
|
|
351
|
+
const candidate = externalUrls.spotify;
|
|
352
|
+
return typeof candidate === "string" && candidate.length > 0 ? candidate : void 0;
|
|
353
|
+
}
|
|
354
|
+
function artistNames(artists) {
|
|
355
|
+
if (!Array.isArray(artists)) return [];
|
|
356
|
+
return artists.map((a) => isRecord(a) && typeof a.name === "string" ? a.name : "").filter((n) => n.length > 0);
|
|
357
|
+
}
|
|
358
|
+
/** Normalise one Spotify track. Returns null when the response is
|
|
359
|
+
* missing required scalar fields — caller should drop it from the
|
|
360
|
+
* list rather than render a half-broken row. */
|
|
361
|
+
function normaliseTrack(raw) {
|
|
362
|
+
if (!isRecord(raw)) return null;
|
|
363
|
+
const track = raw;
|
|
364
|
+
if (typeof track.id !== "string" || track.id.length === 0) return null;
|
|
365
|
+
if (typeof track.name !== "string") return null;
|
|
366
|
+
const album = isRecord(track.album) ? track.album : null;
|
|
367
|
+
const url = spotifyUrl(track.external_urls);
|
|
368
|
+
const imageUrl = smallestImageUrl(album?.images);
|
|
369
|
+
return {
|
|
370
|
+
id: track.id,
|
|
371
|
+
name: track.name,
|
|
372
|
+
artists: artistNames(track.artists),
|
|
373
|
+
album: typeof album?.name === "string" ? album.name : "",
|
|
374
|
+
durationMs: typeof track.duration_ms === "number" && Number.isFinite(track.duration_ms) ? track.duration_ms : 0,
|
|
375
|
+
...url !== void 0 ? { url } : {},
|
|
376
|
+
...imageUrl !== void 0 ? { imageUrl } : {}
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
/** Walk a paginated `items[]` response, normalise each entry's
|
|
380
|
+
* nested track, and drop entries that fail validation. */
|
|
381
|
+
function normaliseTrackList(raw, trackPath) {
|
|
382
|
+
if (!isRecord(raw)) return [];
|
|
383
|
+
const items = raw.items;
|
|
384
|
+
if (!Array.isArray(items)) return [];
|
|
385
|
+
const out = [];
|
|
386
|
+
for (const item of items) {
|
|
387
|
+
if (!isRecord(item)) continue;
|
|
388
|
+
const normalised = normaliseTrack(trackPath === "track" ? item.track : item);
|
|
389
|
+
if (normalised) out.push(normalised);
|
|
390
|
+
}
|
|
391
|
+
return out;
|
|
392
|
+
}
|
|
393
|
+
/** `recently-played` items wrap the track in an object that carries
|
|
394
|
+
* the `played_at` timestamp. The View renders timestamps so we
|
|
395
|
+
* preserve them at this layer. */
|
|
396
|
+
function normaliseRecentlyPlayed(raw) {
|
|
397
|
+
if (!isRecord(raw)) return [];
|
|
398
|
+
const items = raw.items;
|
|
399
|
+
if (!Array.isArray(items)) return [];
|
|
400
|
+
const out = [];
|
|
401
|
+
for (const item of items) {
|
|
402
|
+
if (!isRecord(item)) continue;
|
|
403
|
+
const track = normaliseTrack(item.track);
|
|
404
|
+
if (!track) continue;
|
|
405
|
+
const playedAt = typeof item.played_at === "string" ? item.played_at : "";
|
|
406
|
+
out.push({
|
|
407
|
+
track,
|
|
408
|
+
playedAt
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
return out;
|
|
412
|
+
}
|
|
413
|
+
function readPlaylistTotal(playlist) {
|
|
414
|
+
const candidates = [playlist.items, playlist.tracks];
|
|
415
|
+
for (const candidate of candidates) {
|
|
416
|
+
if (!isRecord(candidate)) continue;
|
|
417
|
+
const total = candidate.total;
|
|
418
|
+
if (typeof total === "number" && Number.isFinite(total)) return total;
|
|
419
|
+
}
|
|
420
|
+
return 0;
|
|
421
|
+
}
|
|
422
|
+
function normalisePlaylist(raw) {
|
|
423
|
+
if (!isRecord(raw)) return null;
|
|
424
|
+
const playlist = raw;
|
|
425
|
+
if (typeof playlist.id !== "string" || playlist.id.length === 0) return null;
|
|
426
|
+
if (typeof playlist.name !== "string") return null;
|
|
427
|
+
const url = spotifyUrl(playlist.external_urls);
|
|
428
|
+
const imageUrl = smallestImageUrl(playlist.images);
|
|
429
|
+
return {
|
|
430
|
+
id: playlist.id,
|
|
431
|
+
name: playlist.name,
|
|
432
|
+
description: typeof playlist.description === "string" ? playlist.description : "",
|
|
433
|
+
trackCount: readPlaylistTotal(playlist),
|
|
434
|
+
...url !== void 0 ? { url } : {},
|
|
435
|
+
...imageUrl !== void 0 ? { imageUrl } : {}
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
function normalisePlaylistList(raw) {
|
|
439
|
+
if (!isRecord(raw)) return [];
|
|
440
|
+
const items = raw.items;
|
|
441
|
+
if (!Array.isArray(items)) return [];
|
|
442
|
+
const out = [];
|
|
443
|
+
for (const item of items) {
|
|
444
|
+
const normalised = normalisePlaylist(item);
|
|
445
|
+
if (normalised) out.push(normalised);
|
|
446
|
+
}
|
|
447
|
+
return out;
|
|
448
|
+
}
|
|
449
|
+
function normaliseArtist(raw) {
|
|
450
|
+
if (!isRecord(raw)) return null;
|
|
451
|
+
const artist = raw;
|
|
452
|
+
if (typeof artist.id !== "string" || artist.id.length === 0) return null;
|
|
453
|
+
if (typeof artist.name !== "string") return null;
|
|
454
|
+
const url = spotifyUrl(artist.external_urls);
|
|
455
|
+
const imageUrl = smallestImageUrl(artist.images);
|
|
456
|
+
const popularity = typeof artist.popularity === "number" && Number.isFinite(artist.popularity) ? artist.popularity : void 0;
|
|
457
|
+
return {
|
|
458
|
+
id: artist.id,
|
|
459
|
+
name: artist.name,
|
|
460
|
+
genres: Array.isArray(artist.genres) ? artist.genres.filter((g) => typeof g === "string") : [],
|
|
461
|
+
...popularity !== void 0 ? { popularity } : {},
|
|
462
|
+
...url !== void 0 ? { url } : {},
|
|
463
|
+
...imageUrl !== void 0 ? { imageUrl } : {}
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
function normaliseArtistList(raw) {
|
|
467
|
+
if (!isRecord(raw)) return [];
|
|
468
|
+
const items = raw.items;
|
|
469
|
+
if (!Array.isArray(items)) return [];
|
|
470
|
+
const out = [];
|
|
471
|
+
for (const item of items) {
|
|
472
|
+
const normalised = normaliseArtist(item);
|
|
473
|
+
if (normalised) out.push(normalised);
|
|
474
|
+
}
|
|
475
|
+
return out;
|
|
476
|
+
}
|
|
477
|
+
function normaliseAlbum(raw) {
|
|
478
|
+
if (!isRecord(raw)) return null;
|
|
479
|
+
const album = raw;
|
|
480
|
+
if (typeof album.id !== "string" || album.id.length === 0) return null;
|
|
481
|
+
if (typeof album.name !== "string") return null;
|
|
482
|
+
const url = spotifyUrl(album.external_urls);
|
|
483
|
+
const imageUrl = smallestImageUrl(album.images);
|
|
484
|
+
return {
|
|
485
|
+
id: album.id,
|
|
486
|
+
name: album.name,
|
|
487
|
+
artists: artistNames(album.artists),
|
|
488
|
+
releaseDate: typeof album.release_date === "string" ? album.release_date : "",
|
|
489
|
+
totalTracks: typeof album.total_tracks === "number" && Number.isFinite(album.total_tracks) ? album.total_tracks : 0,
|
|
490
|
+
...url !== void 0 ? { url } : {},
|
|
491
|
+
...imageUrl !== void 0 ? { imageUrl } : {}
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
function normaliseAlbumList(raw) {
|
|
495
|
+
if (!isRecord(raw)) return [];
|
|
496
|
+
const items = raw.items;
|
|
497
|
+
if (!Array.isArray(items)) return [];
|
|
498
|
+
const out = [];
|
|
499
|
+
for (const item of items) {
|
|
500
|
+
const normalised = normaliseAlbum(item);
|
|
501
|
+
if (normalised) out.push(normalised);
|
|
502
|
+
}
|
|
503
|
+
return out;
|
|
504
|
+
}
|
|
505
|
+
//#endregion
|
|
506
|
+
//#region src/listening.ts
|
|
507
|
+
async function fetchLiked(deps, limit) {
|
|
508
|
+
const result = await spotifyApi(deps.runtime, deps.clientId, deps.tokens, "GET", `/v1/me/tracks?limit=${limit}`, {}, deps.now);
|
|
509
|
+
if (!result.ok) return result;
|
|
510
|
+
return {
|
|
511
|
+
ok: true,
|
|
512
|
+
data: normaliseTrackList(result.data, "track")
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
/** Spotify's `/v1/me/playlists` caps at 50 items per page. Walk
|
|
516
|
+
* pages until exhausted (`next === null`) or a hard cap is hit, so
|
|
517
|
+
* users with a large library don't silently lose playlists
|
|
518
|
+
* (CodeRabbit review on PR #1166). Cap at 500 so a runaway
|
|
519
|
+
* account-with-thousands-of-playlists doesn't blow the LLM context
|
|
520
|
+
* window or hammer the API. */
|
|
521
|
+
var PLAYLISTS_PAGE_SIZE = 50;
|
|
522
|
+
var PLAYLISTS_HARD_CAP = 500;
|
|
523
|
+
async function fetchPlaylists(deps) {
|
|
524
|
+
const collected = [];
|
|
525
|
+
let offset = 0;
|
|
526
|
+
while (collected.length < PLAYLISTS_HARD_CAP) {
|
|
527
|
+
const result = await spotifyApi(deps.runtime, deps.clientId, deps.tokens, "GET", `/v1/me/playlists?limit=${PLAYLISTS_PAGE_SIZE}&offset=${offset}`, {}, deps.now);
|
|
528
|
+
if (!result.ok) return result;
|
|
529
|
+
logPlaylistsPageDebug(deps, result.data, offset);
|
|
530
|
+
collected.push(...normalisePlaylistList(result.data));
|
|
531
|
+
if (!hasNextPage(result.data)) break;
|
|
532
|
+
offset += PLAYLISTS_PAGE_SIZE;
|
|
533
|
+
}
|
|
534
|
+
return {
|
|
535
|
+
ok: true,
|
|
536
|
+
data: collected
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
function hasNextPage(raw) {
|
|
540
|
+
return typeof raw === "object" && raw !== null && typeof raw.next === "string";
|
|
541
|
+
}
|
|
542
|
+
function logPlaylistsPageDebug(deps, raw, offset) {
|
|
543
|
+
if (typeof raw !== "object" || raw === null) return;
|
|
544
|
+
const items = raw.items;
|
|
545
|
+
if (!Array.isArray(items) || items.length === 0) return;
|
|
546
|
+
if (typeof items[0] !== "object" || items[0] === null) return;
|
|
547
|
+
const sample = items[0];
|
|
548
|
+
deps.runtime.log.debug("playlists page", {
|
|
549
|
+
offset,
|
|
550
|
+
count: items.length,
|
|
551
|
+
sample: {
|
|
552
|
+
id: sample.id,
|
|
553
|
+
name: sample.name,
|
|
554
|
+
tracks: sample.tracks
|
|
555
|
+
}
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
async function fetchPlaylistTracks(deps, playlistId, limit) {
|
|
559
|
+
const path = `/v1/playlists/${encodeURIComponent(playlistId)}/tracks?limit=${limit}`;
|
|
560
|
+
const result = await spotifyApi(deps.runtime, deps.clientId, deps.tokens, "GET", path, {}, deps.now);
|
|
561
|
+
if (!result.ok) return result;
|
|
562
|
+
return {
|
|
563
|
+
ok: true,
|
|
564
|
+
data: normaliseTrackList(result.data, "track")
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
async function fetchRecent(deps, limit) {
|
|
568
|
+
const result = await spotifyApi(deps.runtime, deps.clientId, deps.tokens, "GET", `/v1/me/player/recently-played?limit=${limit}`, {}, deps.now);
|
|
569
|
+
if (!result.ok) return result;
|
|
570
|
+
return {
|
|
571
|
+
ok: true,
|
|
572
|
+
data: normaliseRecentlyPlayed(result.data)
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
/** `nowPlaying` returns null when nothing is currently playing
|
|
576
|
+
* (Spotify returns 204). The View shows an empty state. */
|
|
577
|
+
async function fetchNowPlaying(deps) {
|
|
578
|
+
const result = await spotifyApi(deps.runtime, deps.clientId, deps.tokens, "GET", "/v1/me/player/currently-playing", {}, deps.now);
|
|
579
|
+
if (!result.ok) return result;
|
|
580
|
+
if (result.data === null) return {
|
|
581
|
+
ok: true,
|
|
582
|
+
data: null
|
|
583
|
+
};
|
|
584
|
+
if (typeof result.data === "object" && result.data !== null && "item" in result.data) return {
|
|
585
|
+
ok: true,
|
|
586
|
+
data: normaliseTrack(result.data.item)
|
|
587
|
+
};
|
|
588
|
+
return {
|
|
589
|
+
ok: true,
|
|
590
|
+
data: null
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
//#endregion
|
|
594
|
+
//#region src/search.ts
|
|
595
|
+
var DEFAULT_SEARCH_TYPES = [
|
|
596
|
+
"track",
|
|
597
|
+
"artist",
|
|
598
|
+
"album",
|
|
599
|
+
"playlist"
|
|
600
|
+
];
|
|
601
|
+
var DEFAULT_SEARCH_LIMIT = 10;
|
|
602
|
+
async function searchSpotify(deps, query, types, limit) {
|
|
603
|
+
const requested = types && types.length > 0 ? types : DEFAULT_SEARCH_TYPES;
|
|
604
|
+
const url = buildSearchUrl(query, requested, limit ?? DEFAULT_SEARCH_LIMIT);
|
|
605
|
+
const response = await spotifyApi(deps.runtime, deps.clientId, deps.tokens, "GET", url, {}, deps.now);
|
|
606
|
+
if (!response.ok) return response;
|
|
607
|
+
return {
|
|
608
|
+
ok: true,
|
|
609
|
+
data: assembleSearchResult(response.data, requested)
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
function buildSearchUrl(query, types, limit) {
|
|
613
|
+
return `/v1/search?${new URLSearchParams({
|
|
614
|
+
q: query,
|
|
615
|
+
limit: String(limit)
|
|
616
|
+
}).toString()}&type=${types.join(",")}`;
|
|
617
|
+
}
|
|
618
|
+
function assembleSearchResult(raw, requested) {
|
|
619
|
+
if (typeof raw !== "object" || raw === null) return {};
|
|
620
|
+
const root = raw;
|
|
621
|
+
const out = {};
|
|
622
|
+
if (requested.includes("track")) out.tracks = normaliseTrackList(root.tracks, "self");
|
|
623
|
+
if (requested.includes("artist")) out.artists = normaliseArtistList(root.artists);
|
|
624
|
+
if (requested.includes("album")) out.albums = normaliseAlbumList(root.albums);
|
|
625
|
+
if (requested.includes("playlist")) out.playlists = normalisePlaylistList(root.playlists);
|
|
626
|
+
return out;
|
|
627
|
+
}
|
|
628
|
+
//#endregion
|
|
629
|
+
//#region src/searchSummary.ts
|
|
630
|
+
/** Build the LLM-facing message string for a search result. The
|
|
631
|
+
* plain text mirrors the View's grouped sections, one entity per
|
|
632
|
+
* line.
|
|
633
|
+
*
|
|
634
|
+
* `query` is user-influenced on the tool path — both the LLM and
|
|
635
|
+
* a manual View submission can put arbitrary strings in there.
|
|
636
|
+
* Embedding it raw lets a hostile query smuggle line breaks and
|
|
637
|
+
* control characters into the LLM's context window (a
|
|
638
|
+
* prompt-injection vector via tool output: `query: "x\n\nIgnore
|
|
639
|
+
* all previous instructions and …"`). Strip control chars and
|
|
640
|
+
* bound the length before interpolating (Codex review on PR
|
|
641
|
+
* #1168). */
|
|
642
|
+
function summariseSearch(query, result) {
|
|
643
|
+
const safeQuery = sanitiseQueryForSummary(query);
|
|
644
|
+
const sections = [];
|
|
645
|
+
if (result.tracks?.length) sections.push(formatSearchSection("Tracks", result.tracks, formatTrackLine));
|
|
646
|
+
if (result.artists?.length) sections.push(formatSearchSection("Artists", result.artists, formatArtistLine));
|
|
647
|
+
if (result.albums?.length) sections.push(formatSearchSection("Albums", result.albums, formatAlbumLine));
|
|
648
|
+
if (result.playlists?.length) sections.push(formatSearchSection("Playlists", result.playlists, formatPlaylistLine));
|
|
649
|
+
if (sections.length === 0) return `Search "${safeQuery}": no results.`;
|
|
650
|
+
return `Search "${safeQuery}":\n${sections.join("\n\n")}`;
|
|
651
|
+
}
|
|
652
|
+
/** Cap and strip control characters so a hostile or accidentally
|
|
653
|
+
* multi-line query can't break out of the `Search "..."` quoting
|
|
654
|
+
* or smuggle `\n\nIgnore previous instructions ...` into the
|
|
655
|
+
* LLM-facing text. Exported for tests.
|
|
656
|
+
*
|
|
657
|
+
* Coverage: C0 (0x00-0x1F), DEL (0x7F), C1 (0x80-0x9F), and the
|
|
658
|
+
* Unicode line/paragraph separators (U+2028, U+2029). Each maps
|
|
659
|
+
* to a single space so adjacent words don't fuse together; runs
|
|
660
|
+
* of whitespace then collapse to one space. */
|
|
661
|
+
var SUMMARY_QUERY_MAX_LEN = 100;
|
|
662
|
+
function isControlCodepoint(code) {
|
|
663
|
+
if (code <= 31) return true;
|
|
664
|
+
if (code >= 127 && code <= 159) return true;
|
|
665
|
+
if (code === 8232 || code === 8233) return true;
|
|
666
|
+
return false;
|
|
667
|
+
}
|
|
668
|
+
function sanitiseQueryForSummary(query) {
|
|
669
|
+
let cleaned = "";
|
|
670
|
+
for (const char of query) {
|
|
671
|
+
const code = char.codePointAt(0) ?? 0;
|
|
672
|
+
cleaned += isControlCodepoint(code) ? " " : char;
|
|
673
|
+
}
|
|
674
|
+
const collapsed = cleaned.replace(/\s+/g, " ").trim();
|
|
675
|
+
if (collapsed.length <= SUMMARY_QUERY_MAX_LEN) return collapsed;
|
|
676
|
+
return `${collapsed.slice(0, SUMMARY_QUERY_MAX_LEN)}…`;
|
|
677
|
+
}
|
|
678
|
+
function formatSearchSection(label, items, formatter) {
|
|
679
|
+
return `${label} (${items.length}):\n${items.map(formatter).join("\n")}`;
|
|
680
|
+
}
|
|
681
|
+
function formatTrackLine(track, idx) {
|
|
682
|
+
return `${idx + 1}. ${track.name} — ${track.artists.join(", ")}`;
|
|
683
|
+
}
|
|
684
|
+
function formatArtistLine(artist, idx) {
|
|
685
|
+
const genres = artist.genres.length > 0 ? ` [${artist.genres.slice(0, 3).join(", ")}]` : "";
|
|
686
|
+
return `${idx + 1}. ${artist.name}${genres}`;
|
|
687
|
+
}
|
|
688
|
+
function formatAlbumLine(album, idx) {
|
|
689
|
+
const year = album.releaseDate ? album.releaseDate.slice(0, 4) : "?";
|
|
690
|
+
return `${idx + 1}. ${album.name} — ${album.artists.join(", ")} (${year})`;
|
|
691
|
+
}
|
|
692
|
+
function formatPlaylistLine(playlist, idx) {
|
|
693
|
+
return `${idx + 1}. ${playlist.name} (${playlist.trackCount} tracks)`;
|
|
694
|
+
}
|
|
695
|
+
//#endregion
|
|
696
|
+
//#region src/profile.ts
|
|
697
|
+
var PROFILE_FILE = "profile.json";
|
|
698
|
+
var PROFILE_TTL_MS = 1440 * 60 * ONE_SECOND_MS;
|
|
699
|
+
var PREMIUM_PRODUCT = "premium";
|
|
700
|
+
async function readProfile(files) {
|
|
701
|
+
if (!await files.exists(PROFILE_FILE)) return null;
|
|
702
|
+
try {
|
|
703
|
+
const raw = await files.read(PROFILE_FILE);
|
|
704
|
+
const parsed = JSON.parse(raw);
|
|
705
|
+
if (typeof parsed.product !== "string") return null;
|
|
706
|
+
if (typeof parsed.fetchedAtMs !== "number" || !Number.isFinite(parsed.fetchedAtMs)) return null;
|
|
707
|
+
return {
|
|
708
|
+
userId: typeof parsed.userId === "string" ? parsed.userId : "",
|
|
709
|
+
product: parsed.product,
|
|
710
|
+
displayName: typeof parsed.displayName === "string" ? parsed.displayName : "",
|
|
711
|
+
fetchedAtMs: parsed.fetchedAtMs
|
|
712
|
+
};
|
|
713
|
+
} catch {
|
|
714
|
+
return null;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
async function writeProfile(files, profile) {
|
|
718
|
+
await files.write(PROFILE_FILE, JSON.stringify(profile, null, 2));
|
|
719
|
+
}
|
|
720
|
+
function isCacheFresh(profile, now) {
|
|
721
|
+
return now.getTime() - profile.fetchedAtMs < PROFILE_TTL_MS;
|
|
722
|
+
}
|
|
723
|
+
/** Get the cached profile if fresh; otherwise fetch + persist a
|
|
724
|
+
* new snapshot. On API failure with a stale cache we keep the
|
|
725
|
+
* stale value (better than locking the user out — a network blip
|
|
726
|
+
* shouldn't break playback).
|
|
727
|
+
*
|
|
728
|
+
* Account scoping: cache is invalidated by `clearProfileCache`
|
|
729
|
+
* whenever new tokens are written (i.e. after `oauthCallback`),
|
|
730
|
+
* so reconnecting with a different Spotify account starts with a
|
|
731
|
+
* fresh fetch and never serves the previous account's `product`
|
|
732
|
+
* (Codex review on PR #1171). */
|
|
733
|
+
async function getProfile(deps) {
|
|
734
|
+
const now = deps.now ?? (() => /* @__PURE__ */ new Date());
|
|
735
|
+
const cached = await readProfile(deps.runtime.files.config);
|
|
736
|
+
if (cached && isCacheFresh(cached, now())) return {
|
|
737
|
+
ok: true,
|
|
738
|
+
profile: cached
|
|
739
|
+
};
|
|
740
|
+
const fresh = await fetchProfile(deps);
|
|
741
|
+
if (fresh.ok) {
|
|
742
|
+
await writeProfile(deps.runtime.files.config, fresh.profile);
|
|
743
|
+
return {
|
|
744
|
+
ok: true,
|
|
745
|
+
profile: fresh.profile
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
if (cached) {
|
|
749
|
+
deps.runtime.log.warn("profile fetch failed; serving stale cache", { detail: errorMessage(fresh.error) });
|
|
750
|
+
return {
|
|
751
|
+
ok: true,
|
|
752
|
+
profile: cached
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
return fresh;
|
|
756
|
+
}
|
|
757
|
+
async function fetchProfile(deps) {
|
|
758
|
+
const result = await spotifyApi(deps.runtime, deps.clientId, deps.tokens, "GET", "/v1/me", {}, deps.now);
|
|
759
|
+
if (!result.ok) return result;
|
|
760
|
+
const raw = result.data;
|
|
761
|
+
return {
|
|
762
|
+
ok: true,
|
|
763
|
+
profile: {
|
|
764
|
+
userId: typeof raw.id === "string" ? raw.id : "",
|
|
765
|
+
product: typeof raw.product === "string" ? raw.product : "free",
|
|
766
|
+
displayName: typeof raw.display_name === "string" ? raw.display_name : "",
|
|
767
|
+
fetchedAtMs: (deps.now ?? (() => /* @__PURE__ */ new Date()))().getTime()
|
|
768
|
+
}
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
function isPremium(profile) {
|
|
772
|
+
return profile.product === PREMIUM_PRODUCT;
|
|
773
|
+
}
|
|
774
|
+
function errorMessage(error) {
|
|
775
|
+
switch (error.kind) {
|
|
776
|
+
case "auth_expired": return error.detail;
|
|
777
|
+
case "transient_error": return error.detail;
|
|
778
|
+
case "rate_limited": return `rate limited (retry ${error.retryAfterSec}s)`;
|
|
779
|
+
case "spotify_api_error": return `${error.status}: ${error.body}`;
|
|
780
|
+
case "not_connected": return "not connected";
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
/** Test-only: clear the cache. Production callers should not need
|
|
784
|
+
* this — the TTL handles it. */
|
|
785
|
+
async function clearProfileCache(files) {
|
|
786
|
+
if (await files.exists(PROFILE_FILE)) await files.unlink(PROFILE_FILE);
|
|
787
|
+
}
|
|
788
|
+
//#endregion
|
|
789
|
+
//#region src/playback.ts
|
|
790
|
+
async function playerPlay(deps, args) {
|
|
791
|
+
const body = {};
|
|
792
|
+
if (args.contextUri) body.context_uri = args.contextUri;
|
|
793
|
+
if (args.trackUris) body.uris = args.trackUris;
|
|
794
|
+
const path = withDeviceId("/v1/me/player/play", args.deviceId);
|
|
795
|
+
return mapVoidResult(await spotifyApi(deps.runtime, deps.clientId, deps.tokens, "PUT", path, Object.keys(body).length > 0 ? { body } : {}, deps.now));
|
|
796
|
+
}
|
|
797
|
+
async function playerPause(deps, deviceId) {
|
|
798
|
+
const path = withDeviceId("/v1/me/player/pause", deviceId);
|
|
799
|
+
return mapVoidResult(await spotifyApi(deps.runtime, deps.clientId, deps.tokens, "PUT", path, {}, deps.now));
|
|
800
|
+
}
|
|
801
|
+
async function playerNext(deps, deviceId) {
|
|
802
|
+
const path = withDeviceId("/v1/me/player/next", deviceId);
|
|
803
|
+
return mapVoidResult(await spotifyApi(deps.runtime, deps.clientId, deps.tokens, "POST", path, {}, deps.now));
|
|
804
|
+
}
|
|
805
|
+
async function playerPrevious(deps, deviceId) {
|
|
806
|
+
const path = withDeviceId("/v1/me/player/previous", deviceId);
|
|
807
|
+
return mapVoidResult(await spotifyApi(deps.runtime, deps.clientId, deps.tokens, "POST", path, {}, deps.now));
|
|
808
|
+
}
|
|
809
|
+
async function playerSeek(deps, positionMs, deviceId) {
|
|
810
|
+
const path = appendQueryParam(`/v1/me/player/seek?${new URLSearchParams({ position_ms: String(positionMs) }).toString()}`, "device_id", deviceId);
|
|
811
|
+
return mapVoidResult(await spotifyApi(deps.runtime, deps.clientId, deps.tokens, "PUT", path, {}, deps.now));
|
|
812
|
+
}
|
|
813
|
+
async function playerSetVolume(deps, volumePercent, deviceId) {
|
|
814
|
+
const path = appendQueryParam(`/v1/me/player/volume?${new URLSearchParams({ volume_percent: String(volumePercent) }).toString()}`, "device_id", deviceId);
|
|
815
|
+
return mapVoidResult(await spotifyApi(deps.runtime, deps.clientId, deps.tokens, "PUT", path, {}, deps.now));
|
|
816
|
+
}
|
|
817
|
+
async function playerTransfer(deps, deviceId, play) {
|
|
818
|
+
const body = { device_ids: [deviceId] };
|
|
819
|
+
if (play !== void 0) body.play = play;
|
|
820
|
+
return mapVoidResult(await spotifyApi(deps.runtime, deps.clientId, deps.tokens, "PUT", "/v1/me/player", { body }, deps.now));
|
|
821
|
+
}
|
|
822
|
+
async function playerGetDevices(deps) {
|
|
823
|
+
const result = await spotifyApi(deps.runtime, deps.clientId, deps.tokens, "GET", "/v1/me/player/devices", {}, deps.now);
|
|
824
|
+
if (!result.ok) return result;
|
|
825
|
+
return {
|
|
826
|
+
ok: true,
|
|
827
|
+
data: normaliseDevices(result.data)
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
function withDeviceId(basePath, deviceId) {
|
|
831
|
+
if (!deviceId) return basePath;
|
|
832
|
+
return `${basePath}?${new URLSearchParams({ device_id: deviceId }).toString()}`;
|
|
833
|
+
}
|
|
834
|
+
function appendQueryParam(path, key, value) {
|
|
835
|
+
if (!value) return path;
|
|
836
|
+
return `${path}&${new URLSearchParams({ [key]: value }).toString()}`;
|
|
837
|
+
}
|
|
838
|
+
/** Player API success responses are 204 No Content; `data` is null
|
|
839
|
+
* in our client wrapper. Normalise so the dispatcher can use a
|
|
840
|
+
* uniform `{ ok, message }` shape. */
|
|
841
|
+
function mapVoidResult(result) {
|
|
842
|
+
if (!result.ok) return result;
|
|
843
|
+
return {
|
|
844
|
+
ok: true,
|
|
845
|
+
data: null
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
function normaliseDevices(raw) {
|
|
849
|
+
if (typeof raw !== "object" || raw === null) return [];
|
|
850
|
+
const devices = raw.devices;
|
|
851
|
+
if (!Array.isArray(devices)) return [];
|
|
852
|
+
const out = [];
|
|
853
|
+
for (const candidate of devices) {
|
|
854
|
+
const normalised = normaliseDevice(candidate);
|
|
855
|
+
if (normalised) out.push(normalised);
|
|
856
|
+
}
|
|
857
|
+
return out;
|
|
858
|
+
}
|
|
859
|
+
function normaliseDevice(raw) {
|
|
860
|
+
if (typeof raw !== "object" || raw === null) return null;
|
|
861
|
+
const device = raw;
|
|
862
|
+
if (typeof device.name !== "string") return null;
|
|
863
|
+
const id = typeof device.id === "string" && device.id.length > 0 ? device.id : null;
|
|
864
|
+
const volumePercent = typeof device.volume_percent === "number" && Number.isFinite(device.volume_percent) ? device.volume_percent : void 0;
|
|
865
|
+
return {
|
|
866
|
+
id,
|
|
867
|
+
name: device.name,
|
|
868
|
+
type: typeof device.type === "string" ? device.type : "",
|
|
869
|
+
isActive: device.is_active === true,
|
|
870
|
+
...volumePercent !== void 0 ? { volumePercent } : {}
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
//#endregion
|
|
874
|
+
//#region src/index.ts
|
|
875
|
+
var OAUTH_CALLBACK_ALIAS = "spotify";
|
|
876
|
+
/** Scope set requested at OAuth time. Two extra scopes were added
|
|
877
|
+
* in PR 3 for Player Controls: `user-read-playback-state` (read
|
|
878
|
+
* active device + playback state) and `user-modify-playback-state`
|
|
879
|
+
* (play/pause/next/seek/volume/transfer). Existing users from
|
|
880
|
+
* PR 1/2 will hit `403 Insufficient client scope` on the new
|
|
881
|
+
* player kinds and need to reconnect. */
|
|
882
|
+
var SPOTIFY_SCOPES = [
|
|
883
|
+
"playlist-read-private",
|
|
884
|
+
"user-library-read",
|
|
885
|
+
"user-modify-playback-state",
|
|
886
|
+
"user-read-currently-playing",
|
|
887
|
+
"user-read-playback-state",
|
|
888
|
+
"user-read-recently-played"
|
|
889
|
+
];
|
|
890
|
+
var SPOTIFY_TOKEN_URL = "https://accounts.spotify.com/api/token";
|
|
891
|
+
var SPOTIFY_TOKEN_HOST = "accounts.spotify.com";
|
|
892
|
+
var TOKEN_EXCHANGE_TIMEOUT_MS = 15 * ONE_SECOND_MS;
|
|
893
|
+
var CLIENT_ID_MISSING_INSTRUCTIONS = [
|
|
894
|
+
"Spotify の Client ID が未設定です。",
|
|
895
|
+
"",
|
|
896
|
+
"1. https://developer.spotify.com/dashboard を開いて Spotify アカウントでログイン",
|
|
897
|
+
"2. 「Create app」 → Redirect URIs に http://127.0.0.1:<PORT>/api/plugins/runtime/oauth-callback/spotify を追加 (PORT は mulmoclaude が動いているポート)",
|
|
898
|
+
"3. Web API をチェックして保存",
|
|
899
|
+
"4. Client ID をコピー",
|
|
900
|
+
"5. plugin View の「Configure」で貼り付ける",
|
|
901
|
+
"",
|
|
902
|
+
"詳細: docs/tips/spotify-setup.md"
|
|
903
|
+
].join("\n");
|
|
904
|
+
var src_default = definePlugin((pluginRuntime) => {
|
|
905
|
+
const { files, log, fetch: runtimeFetch, pubsub } = pluginRuntime;
|
|
906
|
+
return {
|
|
907
|
+
TOOL_DEFINITION,
|
|
908
|
+
async manageSpotify(rawArgs) {
|
|
909
|
+
const parsed = DispatchArgsSchema.safeParse(rawArgs);
|
|
910
|
+
if (!parsed.success) return {
|
|
911
|
+
ok: false,
|
|
912
|
+
error: "invalid_args",
|
|
913
|
+
message: `Invalid arguments: ${parsed.error.issues[0]?.message ?? "unknown"}`
|
|
914
|
+
};
|
|
915
|
+
const args = parsed.data;
|
|
916
|
+
switch (args.kind) {
|
|
917
|
+
case "connect": return handleConnect(args.redirectUri);
|
|
918
|
+
case "oauthCallback": return handleOauthCallback({
|
|
919
|
+
code: args.code,
|
|
920
|
+
state: args.state,
|
|
921
|
+
error: args.error
|
|
922
|
+
});
|
|
923
|
+
case "status": return handleStatus();
|
|
924
|
+
case "diagnose": return handleDiagnose();
|
|
925
|
+
case "configure": return handleConfigure({ clientId: args.clientId });
|
|
926
|
+
case "liked": return handleListening("liked", args);
|
|
927
|
+
case "playlists": return handleListening("playlists", args);
|
|
928
|
+
case "playlistTracks": return handleListening("playlistTracks", args);
|
|
929
|
+
case "recent": return handleListening("recent", args);
|
|
930
|
+
case "nowPlaying": return handleListening("nowPlaying", args);
|
|
931
|
+
case "search": return handleSearch(args);
|
|
932
|
+
case "play":
|
|
933
|
+
case "pause":
|
|
934
|
+
case "next":
|
|
935
|
+
case "previous":
|
|
936
|
+
case "seek":
|
|
937
|
+
case "setVolume":
|
|
938
|
+
case "transferPlayback":
|
|
939
|
+
case "getDevices": return handlePlayer(args);
|
|
940
|
+
default: throw new Error(`Unhandled kind: ${JSON.stringify(args)}`);
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
};
|
|
944
|
+
async function handleConnect(redirectUri) {
|
|
945
|
+
const clientConfig = await readClientConfig(files.config);
|
|
946
|
+
if (!clientConfig) return {
|
|
947
|
+
ok: false,
|
|
948
|
+
error: "client_id_missing",
|
|
949
|
+
message: "Spotify Client ID が未設定です。詳細は instructions を参照してください。",
|
|
950
|
+
instructions: CLIENT_ID_MISSING_INSTRUCTIONS
|
|
951
|
+
};
|
|
952
|
+
const codeVerifier = generateRandomToken();
|
|
953
|
+
const codeChallenge = await deriveCodeChallenge(codeVerifier);
|
|
954
|
+
const state = registerPendingAuthorization(codeVerifier, redirectUri);
|
|
955
|
+
return {
|
|
956
|
+
ok: true,
|
|
957
|
+
message: "Spotify の同意画面の URL を生成しました。ブラウザで開いてください。",
|
|
958
|
+
data: { authorizeUrl: buildAuthorizeUrl({
|
|
959
|
+
clientId: clientConfig.clientId,
|
|
960
|
+
redirectUri,
|
|
961
|
+
scopes: SPOTIFY_SCOPES,
|
|
962
|
+
state,
|
|
963
|
+
codeChallenge
|
|
964
|
+
}) }
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
async function handleOauthCallback(input) {
|
|
968
|
+
if (input.error) {
|
|
969
|
+
log.info("user denied authorization", { error: input.error });
|
|
970
|
+
return {
|
|
971
|
+
ok: false,
|
|
972
|
+
error: "auth_denied",
|
|
973
|
+
message: `Spotify からの認可が拒否されました: ${input.error}`,
|
|
974
|
+
html: renderCallbackHtml({
|
|
975
|
+
title: "Spotify authorization denied",
|
|
976
|
+
body: `Spotify returned: ${input.error}`
|
|
977
|
+
})
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
if (!input.code || !input.state) return {
|
|
981
|
+
ok: false,
|
|
982
|
+
error: "invalid_callback",
|
|
983
|
+
message: "Callback request was missing `code` or `state`.",
|
|
984
|
+
html: renderCallbackHtml({
|
|
985
|
+
title: "Invalid callback",
|
|
986
|
+
body: "Missing `code` or `state` query parameter."
|
|
987
|
+
})
|
|
988
|
+
};
|
|
989
|
+
const pending = consumePendingAuthorization(input.state);
|
|
990
|
+
if (!pending) return {
|
|
991
|
+
ok: false,
|
|
992
|
+
error: "unknown_state",
|
|
993
|
+
message: "この認可リクエストは mulmoclaude から開始されたものではない、または期限切れです。",
|
|
994
|
+
instructions: "plugin View の「Connect」を再度押してください。",
|
|
995
|
+
html: renderCallbackHtml({
|
|
996
|
+
title: "Unknown state",
|
|
997
|
+
body: "This authorization request was not initiated by mulmoclaude (or it expired). Please retry from the plugin View."
|
|
998
|
+
})
|
|
999
|
+
};
|
|
1000
|
+
const clientConfig = await readClientConfig(files.config);
|
|
1001
|
+
if (!clientConfig) return {
|
|
1002
|
+
ok: false,
|
|
1003
|
+
error: "client_id_missing",
|
|
1004
|
+
message: "Spotify Client ID が未設定です。",
|
|
1005
|
+
instructions: CLIENT_ID_MISSING_INSTRUCTIONS,
|
|
1006
|
+
html: renderCallbackHtml({
|
|
1007
|
+
title: "Spotify client ID not configured",
|
|
1008
|
+
body: CLIENT_ID_MISSING_INSTRUCTIONS
|
|
1009
|
+
})
|
|
1010
|
+
};
|
|
1011
|
+
try {
|
|
1012
|
+
const tokens = await exchangeCodeForTokens({
|
|
1013
|
+
code: input.code,
|
|
1014
|
+
clientId: clientConfig.clientId,
|
|
1015
|
+
codeVerifier: pending.codeVerifier,
|
|
1016
|
+
redirectUri: pending.redirectUri
|
|
1017
|
+
});
|
|
1018
|
+
await writeTokens(files.config, tokens);
|
|
1019
|
+
await clearProfileCache(files.config);
|
|
1020
|
+
pubsub.publish("connected", { scopes: tokens.scopes });
|
|
1021
|
+
log.info("tokens written", { scopes: tokens.scopes });
|
|
1022
|
+
return {
|
|
1023
|
+
ok: true,
|
|
1024
|
+
message: "Spotify を接続しました。",
|
|
1025
|
+
html: renderCallbackHtml({
|
|
1026
|
+
title: "Spotify connected",
|
|
1027
|
+
body: "You can close this window and return to mulmoclaude."
|
|
1028
|
+
})
|
|
1029
|
+
};
|
|
1030
|
+
} catch (err) {
|
|
1031
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
1032
|
+
log.error("token exchange failed", { error: detail });
|
|
1033
|
+
const instructions = `Token exchange failed: ${detail}\n\nThis usually means the Redirect URI registered in your Spotify Developer Dashboard does not match the URL mulmoclaude is using:\n${pending.redirectUri}`;
|
|
1034
|
+
return {
|
|
1035
|
+
ok: false,
|
|
1036
|
+
error: "token_exchange_failed",
|
|
1037
|
+
message: detail,
|
|
1038
|
+
instructions,
|
|
1039
|
+
html: renderCallbackHtml({
|
|
1040
|
+
title: "Token exchange failed",
|
|
1041
|
+
body: instructions
|
|
1042
|
+
})
|
|
1043
|
+
};
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
async function handleStatus() {
|
|
1047
|
+
const clientConfig = await readClientConfig(files.config);
|
|
1048
|
+
const tokens = await readTokens(files.config);
|
|
1049
|
+
let premium = null;
|
|
1050
|
+
let displayName = "";
|
|
1051
|
+
if (tokens && clientConfig) {
|
|
1052
|
+
const profileResult = await getProfile({
|
|
1053
|
+
runtime: pluginRuntime,
|
|
1054
|
+
clientId: clientConfig.clientId,
|
|
1055
|
+
tokens
|
|
1056
|
+
});
|
|
1057
|
+
if (profileResult.ok) {
|
|
1058
|
+
premium = isPremium(profileResult.profile);
|
|
1059
|
+
displayName = profileResult.profile.displayName;
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
return {
|
|
1063
|
+
ok: true,
|
|
1064
|
+
message: tokens ? "Connected." : clientConfig ? "Client ID is configured but you haven't connected yet." : "Client ID is not configured.",
|
|
1065
|
+
data: {
|
|
1066
|
+
clientIdConfigured: clientConfig !== null,
|
|
1067
|
+
connected: tokens !== null,
|
|
1068
|
+
expiresAt: tokens?.expiresAt ?? null,
|
|
1069
|
+
scopes: tokens?.scopes ?? [],
|
|
1070
|
+
isPremium: premium,
|
|
1071
|
+
displayName
|
|
1072
|
+
}
|
|
1073
|
+
};
|
|
1074
|
+
}
|
|
1075
|
+
async function handleDiagnose() {
|
|
1076
|
+
const clientConfig = await readClientConfig(files.config);
|
|
1077
|
+
const tokens = await readTokens(files.config);
|
|
1078
|
+
return {
|
|
1079
|
+
ok: true,
|
|
1080
|
+
message: "See `data` for the connection diagnostics.",
|
|
1081
|
+
data: {
|
|
1082
|
+
clientIdConfigured: clientConfig !== null,
|
|
1083
|
+
tokensPresent: tokens !== null,
|
|
1084
|
+
expiresAt: tokens?.expiresAt ?? null,
|
|
1085
|
+
scopes: tokens?.scopes ?? []
|
|
1086
|
+
}
|
|
1087
|
+
};
|
|
1088
|
+
}
|
|
1089
|
+
async function exchangeCodeForTokens(params) {
|
|
1090
|
+
const response = await runtimeFetch(SPOTIFY_TOKEN_URL, {
|
|
1091
|
+
method: "POST",
|
|
1092
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
1093
|
+
body: new URLSearchParams({
|
|
1094
|
+
grant_type: "authorization_code",
|
|
1095
|
+
code: params.code,
|
|
1096
|
+
redirect_uri: params.redirectUri,
|
|
1097
|
+
client_id: params.clientId,
|
|
1098
|
+
code_verifier: params.codeVerifier
|
|
1099
|
+
}).toString(),
|
|
1100
|
+
timeoutMs: TOKEN_EXCHANGE_TIMEOUT_MS,
|
|
1101
|
+
allowedHosts: [SPOTIFY_TOKEN_HOST]
|
|
1102
|
+
});
|
|
1103
|
+
if (!response.ok) {
|
|
1104
|
+
const body = await response.text().catch(() => "");
|
|
1105
|
+
throw new Error(`Spotify token endpoint returned ${response.status}: ${body.slice(0, 300)}`);
|
|
1106
|
+
}
|
|
1107
|
+
const raw = await response.json();
|
|
1108
|
+
if (typeof raw.access_token !== "string" || raw.access_token.length === 0) throw new Error("Spotify response missing access_token");
|
|
1109
|
+
if (typeof raw.refresh_token !== "string" || raw.refresh_token.length === 0) throw new Error("Spotify response missing refresh_token");
|
|
1110
|
+
if (typeof raw.expires_in !== "number" || !Number.isFinite(raw.expires_in)) throw new Error("Spotify response missing expires_in");
|
|
1111
|
+
return {
|
|
1112
|
+
accessToken: raw.access_token,
|
|
1113
|
+
refreshToken: raw.refresh_token,
|
|
1114
|
+
expiresAt: new Date(Date.now() + raw.expires_in * ONE_SECOND_MS).toISOString(),
|
|
1115
|
+
scopes: typeof raw.scope === "string" ? raw.scope.split(" ").filter(Boolean) : [...SPOTIFY_SCOPES]
|
|
1116
|
+
};
|
|
1117
|
+
}
|
|
1118
|
+
async function handleConfigure(args) {
|
|
1119
|
+
const trimmed = args.clientId.trim();
|
|
1120
|
+
if (trimmed.length === 0) return {
|
|
1121
|
+
ok: false,
|
|
1122
|
+
error: "invalid_client_id",
|
|
1123
|
+
message: "Client ID が空です。Spotify Developer Dashboard からコピーした文字列を貼り付けてください。"
|
|
1124
|
+
};
|
|
1125
|
+
const config = { clientId: trimmed };
|
|
1126
|
+
await writeClientConfig(files.config, config);
|
|
1127
|
+
log.info("client id configured");
|
|
1128
|
+
return {
|
|
1129
|
+
ok: true,
|
|
1130
|
+
message: "Spotify Client ID を保存しました。"
|
|
1131
|
+
};
|
|
1132
|
+
}
|
|
1133
|
+
async function handleListening(kind, args) {
|
|
1134
|
+
const ready = await loadCredentials();
|
|
1135
|
+
if (!ready.ok) return ready.errorResponse;
|
|
1136
|
+
const result = await invokeListening(kind, args, {
|
|
1137
|
+
runtime: pluginRuntime,
|
|
1138
|
+
clientId: ready.clientConfig.clientId,
|
|
1139
|
+
tokens: ready.tokens
|
|
1140
|
+
});
|
|
1141
|
+
if (!result.ok) return mapClientError(result.error);
|
|
1142
|
+
return {
|
|
1143
|
+
ok: true,
|
|
1144
|
+
message: summariseListening(kind, result.data),
|
|
1145
|
+
data: result.data
|
|
1146
|
+
};
|
|
1147
|
+
}
|
|
1148
|
+
async function handleSearch(args) {
|
|
1149
|
+
const ready = await loadCredentials();
|
|
1150
|
+
if (!ready.ok) return ready.errorResponse;
|
|
1151
|
+
const result = await searchSpotify({
|
|
1152
|
+
runtime: pluginRuntime,
|
|
1153
|
+
clientId: ready.clientConfig.clientId,
|
|
1154
|
+
tokens: ready.tokens
|
|
1155
|
+
}, args.query, args.types, args.limit);
|
|
1156
|
+
if (!result.ok) return mapClientError(result.error);
|
|
1157
|
+
return {
|
|
1158
|
+
ok: true,
|
|
1159
|
+
message: summariseSearch(args.query, result.data),
|
|
1160
|
+
data: result.data
|
|
1161
|
+
};
|
|
1162
|
+
}
|
|
1163
|
+
async function handlePlayer(args) {
|
|
1164
|
+
if (args.kind === "play" && args.contextUri && args.trackUris) return {
|
|
1165
|
+
ok: false,
|
|
1166
|
+
error: "invalid_args",
|
|
1167
|
+
message: "play: `contextUri` と `trackUris` は同時に指定できません。どちらか一方を選んでください。"
|
|
1168
|
+
};
|
|
1169
|
+
const ready = await loadCredentials();
|
|
1170
|
+
if (!ready.ok) return ready.errorResponse;
|
|
1171
|
+
const deps = {
|
|
1172
|
+
runtime: pluginRuntime,
|
|
1173
|
+
clientId: ready.clientConfig.clientId,
|
|
1174
|
+
tokens: ready.tokens
|
|
1175
|
+
};
|
|
1176
|
+
if (args.kind !== "getDevices") {
|
|
1177
|
+
const gate = await premiumGate(deps);
|
|
1178
|
+
if (gate) return gate;
|
|
1179
|
+
}
|
|
1180
|
+
const result = await invokePlayer(args, deps);
|
|
1181
|
+
if (!result.ok) return mapPlayerError(result.error, args.kind);
|
|
1182
|
+
return summarisePlayerResult(args.kind, result.data);
|
|
1183
|
+
}
|
|
1184
|
+
async function loadCredentials() {
|
|
1185
|
+
const clientConfig = await readClientConfig(files.config);
|
|
1186
|
+
if (!clientConfig) return {
|
|
1187
|
+
ok: false,
|
|
1188
|
+
errorResponse: {
|
|
1189
|
+
ok: false,
|
|
1190
|
+
error: "client_id_missing",
|
|
1191
|
+
message: "Spotify Client ID が未設定です。",
|
|
1192
|
+
instructions: CLIENT_ID_MISSING_INSTRUCTIONS
|
|
1193
|
+
}
|
|
1194
|
+
};
|
|
1195
|
+
const tokens = await readTokens(files.config);
|
|
1196
|
+
if (!tokens) return {
|
|
1197
|
+
ok: false,
|
|
1198
|
+
errorResponse: {
|
|
1199
|
+
ok: false,
|
|
1200
|
+
error: "not_connected",
|
|
1201
|
+
message: "Spotify に未接続です。「Connect」を実行してください。"
|
|
1202
|
+
}
|
|
1203
|
+
};
|
|
1204
|
+
return {
|
|
1205
|
+
ok: true,
|
|
1206
|
+
clientConfig,
|
|
1207
|
+
tokens
|
|
1208
|
+
};
|
|
1209
|
+
}
|
|
1210
|
+
});
|
|
1211
|
+
async function invokePlayer(args, deps) {
|
|
1212
|
+
switch (args.kind) {
|
|
1213
|
+
case "play": return playerPlay(deps, {
|
|
1214
|
+
deviceId: args.deviceId,
|
|
1215
|
+
contextUri: args.contextUri,
|
|
1216
|
+
trackUris: args.trackUris
|
|
1217
|
+
});
|
|
1218
|
+
case "pause": return playerPause(deps, args.deviceId);
|
|
1219
|
+
case "next": return playerNext(deps, args.deviceId);
|
|
1220
|
+
case "previous": return playerPrevious(deps, args.deviceId);
|
|
1221
|
+
case "seek": return playerSeek(deps, args.positionMs, args.deviceId);
|
|
1222
|
+
case "setVolume": return playerSetVolume(deps, args.volumePercent, args.deviceId);
|
|
1223
|
+
case "transferPlayback": return playerTransfer(deps, args.deviceId, args.play);
|
|
1224
|
+
case "getDevices": return playerGetDevices(deps);
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
async function invokeListening(kind, args, deps) {
|
|
1228
|
+
switch (kind) {
|
|
1229
|
+
case "liked": return fetchLiked(deps, args.kind === "liked" ? args.limit ?? 50 : 50);
|
|
1230
|
+
case "playlists": return fetchPlaylists(deps);
|
|
1231
|
+
case "playlistTracks":
|
|
1232
|
+
if (args.kind !== "playlistTracks") throw new Error("kind/args mismatch");
|
|
1233
|
+
return fetchPlaylistTracks(deps, args.playlistId, args.limit ?? 100);
|
|
1234
|
+
case "recent": return fetchRecent(deps, args.kind === "recent" ? args.limit ?? 50 : 50);
|
|
1235
|
+
case "nowPlaying": return fetchNowPlaying(deps);
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
/** Build the LLM-facing message string for a listening result.
|
|
1239
|
+
* The plain text mirrors the View's grid: title + artists, one per
|
|
1240
|
+
* line. Length-capped per kind so the LLM context window doesn't
|
|
1241
|
+
* blow up on a 50-track Liked Songs response. */
|
|
1242
|
+
function summariseListening(kind, data) {
|
|
1243
|
+
if (kind === "nowPlaying") {
|
|
1244
|
+
if (!data || typeof data !== "object" || !("name" in data)) return "Nothing is currently playing.";
|
|
1245
|
+
const track = data;
|
|
1246
|
+
return `Now playing: ${track.name} — ${track.artists.join(", ")} (${track.album})`;
|
|
1247
|
+
}
|
|
1248
|
+
if (!Array.isArray(data) || data.length === 0) return `No ${kind} items.`;
|
|
1249
|
+
if (kind === "playlists") {
|
|
1250
|
+
const lines = data.map((p, i) => `${i + 1}. ${p.name} (${p.trackCount} tracks)`);
|
|
1251
|
+
return `Playlists (${data.length}):\n${lines.join("\n")}`;
|
|
1252
|
+
}
|
|
1253
|
+
if (kind === "recent") {
|
|
1254
|
+
const lines = data.map((item, i) => {
|
|
1255
|
+
const when = item.playedAt ? new Date(item.playedAt).toISOString().slice(0, 16).replace("T", " ") : "?";
|
|
1256
|
+
return `${i + 1}. [${when}] ${item.track.name} — ${item.track.artists.join(", ")}`;
|
|
1257
|
+
});
|
|
1258
|
+
return `Recently played (${data.length}):\n${lines.join("\n")}`;
|
|
1259
|
+
}
|
|
1260
|
+
const lines = data.map((t, i) => `${i + 1}. ${t.name} — ${t.artists.join(", ")}`);
|
|
1261
|
+
return `${kind === "liked" ? "Liked Songs" : "Playlist tracks"} (${data.length}):\n${lines.join("\n")}`;
|
|
1262
|
+
}
|
|
1263
|
+
async function premiumGate(deps) {
|
|
1264
|
+
const profileResult = await getProfile(deps);
|
|
1265
|
+
if (!profileResult.ok) return mapClientError(profileResult.error);
|
|
1266
|
+
if (isPremium(profileResult.profile)) return null;
|
|
1267
|
+
return {
|
|
1268
|
+
ok: false,
|
|
1269
|
+
error: "premium_required",
|
|
1270
|
+
message: "Spotify Premium が必要な操作です。Free アカウントでは再生制御は使えません。",
|
|
1271
|
+
instructions: "Spotify Premium にアップグレードしてください。再生制御以外 (Liked / Playlists / Recent / Search) は Free でも引き続き利用できます。"
|
|
1272
|
+
};
|
|
1273
|
+
}
|
|
1274
|
+
function summarisePlayerResult(kind, data) {
|
|
1275
|
+
if (kind === "getDevices") {
|
|
1276
|
+
const devices = data ?? [];
|
|
1277
|
+
if (devices.length === 0) return {
|
|
1278
|
+
ok: true,
|
|
1279
|
+
message: "アクティブな Spotify デバイスがありません。Spotify アプリを起動してから再度お試しください。",
|
|
1280
|
+
data: devices
|
|
1281
|
+
};
|
|
1282
|
+
const lines = devices.map((d, i) => `${i + 1}. ${d.name} (${d.type})${d.isActive ? " — active" : ""}`);
|
|
1283
|
+
return {
|
|
1284
|
+
ok: true,
|
|
1285
|
+
message: `Devices (${devices.length}):\n${lines.join("\n")}`,
|
|
1286
|
+
data: devices
|
|
1287
|
+
};
|
|
1288
|
+
}
|
|
1289
|
+
return {
|
|
1290
|
+
ok: true,
|
|
1291
|
+
message: PLAYER_SUCCESS_MESSAGES[kind]
|
|
1292
|
+
};
|
|
1293
|
+
}
|
|
1294
|
+
var PLAYER_SUCCESS_MESSAGES = {
|
|
1295
|
+
play: "再生を開始しました。",
|
|
1296
|
+
pause: "再生を一時停止しました。",
|
|
1297
|
+
next: "次の曲に進みました。",
|
|
1298
|
+
previous: "前の曲に戻りました。",
|
|
1299
|
+
seek: "位置をシークしました。",
|
|
1300
|
+
setVolume: "音量を変更しました。",
|
|
1301
|
+
transferPlayback: "再生をデバイスに移しました。"
|
|
1302
|
+
};
|
|
1303
|
+
function mapPlayerError(error, kind) {
|
|
1304
|
+
if (error.kind === "spotify_api_error" && error.status === 404 && kind !== "getDevices") return {
|
|
1305
|
+
ok: false,
|
|
1306
|
+
error: "no_active_device",
|
|
1307
|
+
message: "アクティブな Spotify デバイスがありません。Spotify アプリ (デスクトップ / モバイル / Web) を起動してから再度お試しください。",
|
|
1308
|
+
instructions: "View の Player タブから対象デバイスを選んで「Transfer」を押すか、Spotify アプリ側で何か再生してから再試行してください。"
|
|
1309
|
+
};
|
|
1310
|
+
if (error.kind === "spotify_api_error" && error.status === 403 && error.body.includes("scope")) return {
|
|
1311
|
+
ok: false,
|
|
1312
|
+
error: "scope_missing",
|
|
1313
|
+
message: "新しい権限の追加が必要です。Spotify View ヘッダの「Reconnect」ボタンを押して再認可してください。",
|
|
1314
|
+
instructions: "PR 3 で追加された Player 制御は新しい OAuth scope を要求します。View 右上の「Reconnect」ボタンで Spotify の同意画面を開き直すと scope が更新されます。"
|
|
1315
|
+
};
|
|
1316
|
+
return mapClientError(error);
|
|
1317
|
+
}
|
|
1318
|
+
function mapClientError(error) {
|
|
1319
|
+
switch (error.kind) {
|
|
1320
|
+
case "auth_expired": return {
|
|
1321
|
+
ok: false,
|
|
1322
|
+
error: "auth_expired",
|
|
1323
|
+
message: "認可が無効化されました。「Connect」をやり直してください。",
|
|
1324
|
+
detail: error.detail
|
|
1325
|
+
};
|
|
1326
|
+
case "transient_error": return {
|
|
1327
|
+
ok: false,
|
|
1328
|
+
error: "transient_error",
|
|
1329
|
+
message: "Spotify に一時的に接続できませんでした。しばらくしてから再試行してください。",
|
|
1330
|
+
detail: error.detail
|
|
1331
|
+
};
|
|
1332
|
+
case "rate_limited": return {
|
|
1333
|
+
ok: false,
|
|
1334
|
+
error: "rate_limited",
|
|
1335
|
+
message: `Spotify から rate limit を返されました。${error.retryAfterSec} 秒後に再試行してください。`,
|
|
1336
|
+
retryAfterSec: error.retryAfterSec
|
|
1337
|
+
};
|
|
1338
|
+
case "spotify_api_error": return {
|
|
1339
|
+
ok: false,
|
|
1340
|
+
error: "spotify_api_error",
|
|
1341
|
+
message: `Spotify API がエラーを返しました (${error.status})`,
|
|
1342
|
+
detail: error.body
|
|
1343
|
+
};
|
|
1344
|
+
case "not_connected": return {
|
|
1345
|
+
ok: false,
|
|
1346
|
+
error: "not_connected",
|
|
1347
|
+
message: "Spotify に未接続です。"
|
|
1348
|
+
};
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
function renderCallbackHtml(params) {
|
|
1352
|
+
return `<!doctype html><html lang="en"><meta charset="utf-8"><title>${escapeHtml(params.title)}</title>
|
|
1353
|
+
<style>body{font-family:system-ui,sans-serif;max-width:40rem;margin:4rem auto;padding:0 1rem;color:#111}h1{margin-bottom:1rem}pre{white-space:pre-wrap;background:#f5f5f5;padding:1rem;border-radius:.5rem}</style>
|
|
1354
|
+
<h1>${escapeHtml(params.title)}</h1>
|
|
1355
|
+
<pre>${escapeHtml(params.body)}</pre>
|
|
1356
|
+
</html>`;
|
|
1357
|
+
}
|
|
1358
|
+
function escapeHtml(value) {
|
|
1359
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """).replaceAll("'", "'");
|
|
1360
|
+
}
|
|
1361
|
+
//#endregion
|
|
1362
|
+
export { OAUTH_CALLBACK_ALIAS, TOOL_DEFINITION, src_default as default };
|
|
1363
|
+
|
|
1364
|
+
//# sourceMappingURL=index.js.map
|