pi-spotify-widget 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.
@@ -0,0 +1,413 @@
1
+ /**
2
+ * pi-spotify-widget - Pi coding-agent extension
3
+ */
4
+
5
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
6
+ import { isConfigured } from "../lib/auth-store.ts";
7
+ import {
8
+ formatSecretSafeStatus,
9
+ login,
10
+ logout,
11
+ openExternalUrl,
12
+ } from "../lib/spotify-auth.ts";
13
+ import {
14
+ fetchCurrentlyPlaying,
15
+ pausePlayback,
16
+ playTrackUri,
17
+ resumePlayback,
18
+ skipToNext,
19
+ skipToPrevious,
20
+ spotifyUrlToTrackUri,
21
+ } from "../lib/spotify-api.ts";
22
+ import {
23
+ toPlaybackSnapshot,
24
+ type PlaybackSnapshot,
25
+ } from "../lib/playback-snapshot.ts";
26
+ import {
27
+ formatPlaybackHelpMarkdown,
28
+ playbackCommandById,
29
+ } from "../lib/playback-commands.ts";
30
+ import {
31
+ computeRefreshDelayMs,
32
+ shouldRefreshAtTrackEnd,
33
+ trackIdentity,
34
+ } from "../lib/refresh-schedule.ts";
35
+ import {
36
+ REDIRECT_URI,
37
+ resolveClientId,
38
+ saveStoredClientId,
39
+ SPOTIFY_DASHBOARD_URL,
40
+ WIDGET_ID,
41
+ } from "../lib/spotify-config.ts";
42
+ import { openSpotifySharePost } from "../lib/x-share.ts";
43
+ import {
44
+ renderError,
45
+ renderIdle,
46
+ renderNoAuth,
47
+ renderSnapshot,
48
+ } from "../lib/widget-render.ts";
49
+
50
+ type SetWidget = (id: string, lines: string[] | undefined) => void;
51
+ type Theme = {
52
+ fg: (color: "accent" | "dim" | "muted" | "success" | "error", text: string) => string;
53
+ };
54
+
55
+ type WidgetCtx = {
56
+ hasUI: boolean;
57
+ ui: {
58
+ notify: (msg: string, level: "info" | "warning" | "error") => void;
59
+ setWidget: SetWidget;
60
+ setStatus: (key: string, text: string | undefined) => void;
61
+ theme: Theme;
62
+ };
63
+ };
64
+
65
+ const COMMANDS = [
66
+ ["login", "open Spotify Developer Dashboard, enter Client ID, OAuth PKCE login"],
67
+ ["status", "show secret-safe Spotify auth status"],
68
+ ["logout", "remove stored Spotify tokens"],
69
+ ["refresh", "force playback snapshot fetch"],
70
+ ["prev", "skip to previous track"],
71
+ ["next", "skip to next track"],
72
+ ["pause", "pause playback"],
73
+ ["play", "resume playback"],
74
+ ["help", "show playback command list for AI/user"],
75
+ ["share", "open X compose with now playing (soccer-widget style)"],
76
+ ["play-uri", "play a Spotify track URL or URI"],
77
+ ] as const;
78
+
79
+ const DISPLAY_TICK_MS = 1000;
80
+ const TRACK_END_FETCH_COOLDOWN_MS = 800;
81
+
82
+ let refreshTimer: ReturnType<typeof setTimeout> | null = null;
83
+ let displayTimer: ReturnType<typeof setInterval> | null = null;
84
+ let lastSnapshot: PlaybackSnapshot | null = null;
85
+ let lastTrackEndFetchAt = 0;
86
+ let widgetCtx: WidgetCtx | null = null;
87
+
88
+ function clearRefreshTimer(): void {
89
+ if (refreshTimer) {
90
+ clearTimeout(refreshTimer);
91
+ refreshTimer = null;
92
+ }
93
+ }
94
+
95
+ function clearDisplayTimer(): void {
96
+ if (displayTimer) {
97
+ clearInterval(displayTimer);
98
+ displayTimer = null;
99
+ }
100
+ }
101
+
102
+ function clearAllTimers(): void {
103
+ clearRefreshTimer();
104
+ clearDisplayTimer();
105
+ widgetCtx = null;
106
+ }
107
+
108
+ function isStaleContextError(error: unknown): boolean {
109
+ return error instanceof Error && error.message.includes("This extension ctx is stale");
110
+ }
111
+
112
+ function showText(
113
+ ctx: { ui: { notify: (msg: string, level: "info" | "warning" | "error") => void } },
114
+ text: string,
115
+ level: "info" | "warning" | "error" = "info",
116
+ ): void {
117
+ const firstLine = text.split(/\r?\n/)[0] ?? text;
118
+ ctx.ui.notify(firstLine, level);
119
+ }
120
+
121
+ function formatStatusMarkdown(): string {
122
+ return ["### Spotify status", "", "```text", formatSecretSafeStatus(), "```"].join("\n");
123
+ }
124
+
125
+ function statusFooterText(): string | undefined {
126
+ if (!isConfigured()) return "Spotify: not logged in";
127
+ const line = formatSecretSafeStatus().split(/\r?\n/).find((entry) => entry.startsWith("expires:"));
128
+ return line ? `Spotify · ${line.replace("expires: ", "expires ")}` : "Spotify · logged in";
129
+ }
130
+
131
+ async function showCommandOutput(
132
+ pi: ExtensionAPI,
133
+ ctx: ExtensionCommandContext,
134
+ kind: string,
135
+ content: string,
136
+ level: "info" | "warning" | "error" = "info",
137
+ ): Promise<void> {
138
+ showText(ctx, content, level);
139
+ pi.sendMessage({ customType: "spotify-widget", content, display: true, details: { kind } });
140
+ }
141
+
142
+ function clientIdGuideText(): string {
143
+ return [
144
+ "Spotify Developer setup:",
145
+ `1. Open: ${SPOTIFY_DASHBOARD_URL}`,
146
+ "2. Create an app in the Spotify Developer Dashboard.",
147
+ `3. Add redirect URI: ${REDIRECT_URI}`,
148
+ "4. Copy the Client ID (Client Secret is not needed for PKCE).",
149
+ "5. Paste the Client ID into the Pi prompt below.",
150
+ ].join("\n");
151
+ }
152
+
153
+ async function ensureClientIdForLogin(ctx: ExtensionCommandContext): Promise<string | undefined> {
154
+ if (!ctx.hasUI) throw new Error("Spotify login requires interactive UI. Run /spotify:login inside Pi.");
155
+ let clientId = resolveClientId();
156
+ if (clientId) {
157
+ showText(ctx, "Client ID is already saved. Opening Spotify authorization in your browser.", "info");
158
+ return clientId;
159
+ }
160
+ showText(ctx, clientIdGuideText(), "info");
161
+ openExternalUrl(SPOTIFY_DASHBOARD_URL);
162
+ const entered = await ctx.ui.input("Spotify Client ID:", "paste Client ID from Developer Dashboard");
163
+ clientId = String(entered ?? "").trim();
164
+ if (!clientId) {
165
+ showText(ctx, "Spotify Client ID was not saved.", "warning");
166
+ return undefined;
167
+ }
168
+ saveStoredClientId(clientId);
169
+ showText(ctx, "Saved Spotify Client ID for pi-spotify-widget.");
170
+ return clientId;
171
+ }
172
+
173
+ function applySnapshotToWidget(setWidget: SetWidget, theme: Theme, snapshot: PlaybackSnapshot | null, stale = false): void {
174
+ if (!snapshot?.track) {
175
+ const idle: PlaybackSnapshot = snapshot ?? { track: "", artist: "", isPlaying: false, fetchedAt: Date.now() };
176
+ setWidget(WIDGET_ID, renderIdle(idle, theme, { stale }));
177
+ return;
178
+ }
179
+ setWidget(WIDGET_ID, renderSnapshot(snapshot, theme, { stale }));
180
+ }
181
+
182
+ async function refreshWidget(setWidget: SetWidget, theme: Theme): Promise<void> {
183
+ if (!isConfigured()) {
184
+ lastSnapshot = null;
185
+ setWidget(WIDGET_ID, renderNoAuth(theme));
186
+ return;
187
+ }
188
+ const previousIdentity = trackIdentity(lastSnapshot);
189
+ try {
190
+ const raw = await fetchCurrentlyPlaying();
191
+ const snapshot = toPlaybackSnapshot(raw);
192
+ if (snapshot) {
193
+ lastSnapshot = snapshot;
194
+ applySnapshotToWidget(setWidget, theme, snapshot);
195
+ } else {
196
+ lastSnapshot = { track: "", artist: "", isPlaying: false, fetchedAt: Date.now() };
197
+ applySnapshotToWidget(setWidget, theme, lastSnapshot);
198
+ }
199
+ } catch (error) {
200
+ const message = error instanceof Error ? error.message : String(error);
201
+ if (lastSnapshot?.track || lastSnapshot) {
202
+ applySnapshotToWidget(setWidget, theme, lastSnapshot, true);
203
+ return;
204
+ }
205
+ setWidget(WIDGET_ID, renderError(message, theme));
206
+ return;
207
+ }
208
+ const nextIdentity = trackIdentity(lastSnapshot);
209
+ if (previousIdentity && nextIdentity && previousIdentity !== nextIdentity) scheduleNextRefresh(0);
210
+ }
211
+
212
+ function scheduleNextRefresh(delayOverrideMs?: number): void {
213
+ clearRefreshTimer();
214
+ if (!widgetCtx) return;
215
+ const delay = delayOverrideMs ?? computeRefreshDelayMs(lastSnapshot);
216
+ const run = () => {
217
+ if (!widgetCtx) return;
218
+ void refreshWidget((id, lines) => widgetCtx!.ui.setWidget(id, lines), widgetCtx.ui.theme)
219
+ .catch((error) => {
220
+ if (isStaleContextError(error)) { clearRefreshTimer(); return; }
221
+ const message = error instanceof Error ? error.message : String(error);
222
+ try { widgetCtx?.ui.setWidget(WIDGET_ID, renderError(message, widgetCtx.ui.theme)); } catch { /* ignore */ }
223
+ })
224
+ .finally(() => scheduleNextRefresh());
225
+ };
226
+ if (delay <= 0) { run(); return; }
227
+ refreshTimer = setTimeout(run, delay);
228
+ refreshTimer.unref?.();
229
+ }
230
+
231
+ function resetDisplayTimer(ctx: WidgetCtx): void {
232
+ clearDisplayTimer();
233
+ displayTimer = setInterval(() => {
234
+ if (!lastSnapshot) return;
235
+ try {
236
+ ctx.ui.setWidget(WIDGET_ID, lastSnapshot.track ? renderSnapshot(lastSnapshot, ctx.ui.theme) : renderIdle(lastSnapshot, ctx.ui.theme));
237
+ } catch (error) {
238
+ if (isStaleContextError(error)) clearDisplayTimer();
239
+ }
240
+ if (!lastSnapshot.isPlaying || !lastSnapshot.track) return;
241
+ if (!shouldRefreshAtTrackEnd(lastSnapshot)) return;
242
+ const now = Date.now();
243
+ if (now - lastTrackEndFetchAt < TRACK_END_FETCH_COOLDOWN_MS) return;
244
+ lastTrackEndFetchAt = now;
245
+ void refreshWidget((id, lines) => ctx.ui.setWidget(id, lines), ctx.ui.theme).finally(() => scheduleNextRefresh());
246
+ }, DISPLAY_TICK_MS);
247
+ displayTimer.unref?.();
248
+ }
249
+
250
+ function bindWidgetContext(ctx: WidgetCtx): void {
251
+ widgetCtx = ctx;
252
+ scheduleNextRefresh();
253
+ resetDisplayTimer(ctx);
254
+ }
255
+
256
+ async function runPlaybackControl(command: "prev" | "next" | "pause" | "play"): Promise<string> {
257
+ if (command === "prev") { await skipToPrevious(); return "Spotify: previous track."; }
258
+ if (command === "next") { await skipToNext(); return "Spotify: next track."; }
259
+ if (command === "pause") { await pausePlayback(); return "Spotify: paused."; }
260
+ await resumePlayback();
261
+ return "Spotify: resumed.";
262
+ }
263
+
264
+ function shareOutputMarkdown(result: { mode: "api" | "intent"; text: string; tweetUrl?: string; intentUrl?: string }): string {
265
+ if (result.mode === "api") {
266
+ const parts = ["### Shared on X", "", "```text", result.text, "```"];
267
+ if (result.tweetUrl) parts.push("", `Posted: ${result.tweetUrl}`);
268
+ return parts.join("\n");
269
+ }
270
+ const parts = [
271
+ "### Share on X",
272
+ "",
273
+ "ブラウザで X の投稿画面を開きます。開かない場合は次の URL をクリックしてください:",
274
+ "",
275
+ ];
276
+ if (result.intentUrl) parts.push(result.intentUrl, "");
277
+ parts.push("```text", result.text, "```");
278
+ return parts.join("\n");
279
+ }
280
+
281
+ async function handleSpotifyCommand(pi: ExtensionAPI, args: string, ctx: WidgetCtx): Promise<void> {
282
+ const theme = ctx.ui.theme;
283
+ const trimmed = String(args ?? "").trim();
284
+ const [commandRaw] = trimmed.split(/\s+/).filter(Boolean);
285
+ const command = (commandRaw ?? "").toLowerCase();
286
+
287
+ if (command === "login") {
288
+ try {
289
+ const clientId = await ensureClientIdForLogin(ctx as ExtensionCommandContext);
290
+ if (!clientId) return;
291
+ await login({
292
+ clientId,
293
+ onAuthorizeUrl: (authorizeUrl) => {
294
+ void showCommandOutput(pi, ctx as ExtensionCommandContext, "authorize-url", ["### Spotify authorization", "", authorizeUrl].join("\n"));
295
+ },
296
+ });
297
+ showText(ctx, "Spotify login successful.");
298
+ await refreshWidget((id, lines) => ctx.ui.setWidget(id, lines), theme);
299
+ scheduleNextRefresh(0);
300
+ } catch (error) {
301
+ showText(ctx, `Spotify login failed: ${error instanceof Error ? error.message : String(error)}`, "warning");
302
+ }
303
+ return;
304
+ }
305
+
306
+ if (command === "status") {
307
+ await showCommandOutput(pi, ctx as ExtensionCommandContext, "status", formatStatusMarkdown());
308
+ ctx.ui.setStatus("spotify", statusFooterText());
309
+ return;
310
+ }
311
+
312
+ if (command === "logout") {
313
+ logout();
314
+ lastSnapshot = null;
315
+ ctx.ui.setWidget(WIDGET_ID, renderNoAuth(theme));
316
+ showText(ctx, "Spotify logout complete.");
317
+ scheduleNextRefresh();
318
+ return;
319
+ }
320
+
321
+ if (command === "refresh") {
322
+ await refreshWidget((id, lines) => ctx.ui.setWidget(id, lines), theme);
323
+ scheduleNextRefresh(0);
324
+ showText(ctx, "Spotify playback snapshot refreshed.");
325
+ return;
326
+ }
327
+
328
+ if (command === "help") {
329
+ await showCommandOutput(pi, ctx as ExtensionCommandContext, "help", formatPlaybackHelpMarkdown());
330
+ return;
331
+ }
332
+
333
+ if (command === "share") {
334
+ try {
335
+ let snapshot = lastSnapshot;
336
+ if (!snapshot?.track) {
337
+ await refreshWidget((id, lines) => ctx.ui.setWidget(id, lines), theme);
338
+ snapshot = lastSnapshot;
339
+ }
340
+ if (!snapshot?.track) {
341
+ showText(ctx, "Nothing playing to share.", "warning");
342
+ return;
343
+ }
344
+ const customPrefix = trimmed.slice(command.length).trim();
345
+ const result = openSpotifySharePost(snapshot, { customPrefix: customPrefix || undefined });
346
+ await showCommandOutput(pi, ctx as ExtensionCommandContext, "share", shareOutputMarkdown(result));
347
+ showText(ctx, "Opening X compose. Screenshot upload is manual.");
348
+ } catch (error) {
349
+ showText(ctx, error instanceof Error ? error.message : String(error), "warning");
350
+ }
351
+ return;
352
+ }
353
+
354
+ if (command === "play-uri" || command === "playuri") {
355
+ try {
356
+ const uriArg = trimmed.slice(command.length).trim();
357
+ if (!uriArg) {
358
+ showText(ctx, "Usage: /spotify:play-uri <spotify-track-url-or-uri>", "warning");
359
+ return;
360
+ }
361
+ await playTrackUri(spotifyUrlToTrackUri(uriArg));
362
+ await refreshWidget((id, lines) => ctx.ui.setWidget(id, lines), theme);
363
+ scheduleNextRefresh(0);
364
+ showText(ctx, "Spotify: playing requested track.");
365
+ } catch (error) {
366
+ showText(ctx, error instanceof Error ? error.message : String(error), "warning");
367
+ }
368
+ return;
369
+ }
370
+
371
+ const playback = playbackCommandById(command);
372
+ if (playback) {
373
+ try {
374
+ showText(ctx, await runPlaybackControl(playback.id));
375
+ await refreshWidget((id, lines) => ctx.ui.setWidget(id, lines), theme);
376
+ scheduleNextRefresh(0);
377
+ } catch (error) {
378
+ showText(ctx, error instanceof Error ? error.message : String(error), "warning");
379
+ }
380
+ return;
381
+ }
382
+
383
+ showText(ctx, `Unknown Spotify command: ${command || "(empty)"}. Try /spotify:help.`, "warning");
384
+ }
385
+
386
+ export const __testing = {
387
+ clearAllTimers,
388
+ clearDisplayTimer,
389
+ clearRefreshTimer,
390
+ getLastSnapshot: () => lastSnapshot,
391
+ refreshWidget,
392
+ resetLastSnapshot: () => { lastSnapshot = null; },
393
+ scheduleNextRefresh,
394
+ };
395
+
396
+ export default function spotifyWidgetExtension(pi: ExtensionAPI) {
397
+ pi.on("session_start", async (_event, ctx) => {
398
+ if (!ctx.hasUI) return;
399
+ await refreshWidget((id, lines) => ctx.ui.setWidget(id, lines), ctx.ui.theme);
400
+ bindWidgetContext(ctx);
401
+ });
402
+ pi.on("session_shutdown", () => clearAllTimers());
403
+ for (const [command, description] of COMMANDS) {
404
+ pi.registerCommand(`spotify:${command}`, {
405
+ description: `Spotify: ${description}.`,
406
+ handler: async (args, ctx) => {
407
+ if (!ctx.hasUI) return;
408
+ const suffix = String(args ?? "").trim();
409
+ await handleSpotifyCommand(pi, suffix ? `${command} ${suffix}` : command, ctx);
410
+ },
411
+ });
412
+ }
413
+ }
@@ -0,0 +1,83 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { AUTH_FILE_NAME } from "./spotify-config.ts";
5
+
6
+ export interface SpotifyAuthRecord {
7
+ accessToken: string;
8
+ refreshToken: string;
9
+ expiresAt: number;
10
+ scope: string;
11
+ updatedAt: string;
12
+ }
13
+
14
+ export function getAgentDir(): string {
15
+ const override = process.env.PI_SPOTIFY_AGENT_DIR?.trim();
16
+ if (override) return override;
17
+ return join(homedir(), ".pi", "agent");
18
+ }
19
+
20
+ export function getAuthFilePath(): string {
21
+ return join(getAgentDir(), AUTH_FILE_NAME);
22
+ }
23
+
24
+ export function authFileExists(): boolean {
25
+ return existsSync(getAuthFilePath());
26
+ }
27
+
28
+ function ensureAgentDir(): void {
29
+ try {
30
+ mkdirSync(getAgentDir(), { recursive: true });
31
+ } catch {
32
+ // ignore
33
+ }
34
+ }
35
+
36
+ export function loadAuth(): SpotifyAuthRecord | null {
37
+ const file = getAuthFilePath();
38
+ try {
39
+ if (!existsSync(file)) return null;
40
+ const parsed = JSON.parse(readFileSync(file, "utf-8")) as Partial<SpotifyAuthRecord>;
41
+ if (
42
+ typeof parsed.accessToken !== "string"
43
+ || typeof parsed.refreshToken !== "string"
44
+ || typeof parsed.expiresAt !== "number"
45
+ ) {
46
+ return null;
47
+ }
48
+ return {
49
+ accessToken: parsed.accessToken,
50
+ refreshToken: parsed.refreshToken,
51
+ expiresAt: parsed.expiresAt,
52
+ scope: typeof parsed.scope === "string" ? parsed.scope : "",
53
+ updatedAt: typeof parsed.updatedAt === "string" ? parsed.updatedAt : new Date().toISOString(),
54
+ };
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ export function saveAuth(record: SpotifyAuthRecord): void {
61
+ ensureAgentDir();
62
+ const file = getAuthFilePath();
63
+ writeFileSync(file, JSON.stringify(record, null, 2), "utf-8");
64
+ try {
65
+ chmodSync(file, 0o600);
66
+ } catch {
67
+ // ignore chmod errors on platforms without POSIX modes
68
+ }
69
+ }
70
+
71
+ export function clearAuth(): void {
72
+ const file = getAuthFilePath();
73
+ try {
74
+ if (existsSync(file)) rmSync(file);
75
+ } catch {
76
+ // ignore
77
+ }
78
+ }
79
+
80
+ export function isConfigured(): boolean {
81
+ const auth = loadAuth();
82
+ return Boolean(auth?.accessToken && auth.refreshToken);
83
+ }
@@ -0,0 +1,93 @@
1
+ export type PlaybackCommandId = "prev" | "next" | "pause" | "play";
2
+
3
+ export interface PlaybackCommandSpec {
4
+ id: PlaybackCommandId;
5
+ command: string;
6
+ spotifyAction: string;
7
+ description: string;
8
+ userPhrases: string[];
9
+ }
10
+
11
+ export const PLAYBACK_COMMANDS: PlaybackCommandSpec[] = [
12
+ {
13
+ id: "prev",
14
+ command: "/spotify:prev",
15
+ spotifyAction: "skip to previous track",
16
+ description: "前の曲に戻る",
17
+ userPhrases: ["前の曲", "前へ", "戻して", "previous track", "go back", "last song"],
18
+ },
19
+ {
20
+ id: "next",
21
+ command: "/spotify:next",
22
+ spotifyAction: "skip to next track",
23
+ description: "次の曲に進む",
24
+ userPhrases: ["次の曲", "次へ", "スキップ", "skip", "next track", "next song"],
25
+ },
26
+ {
27
+ id: "pause",
28
+ command: "/spotify:pause",
29
+ spotifyAction: "pause playback",
30
+ description: "再生を一時停止する(停止)",
31
+ userPhrases: ["止めて", "停止", "ストップ", "一時停止", "pause", "stop", "stop music"],
32
+ },
33
+ {
34
+ id: "play",
35
+ command: "/spotify:play",
36
+ spotifyAction: "resume playback",
37
+ description: "再生を再開する",
38
+ userPhrases: ["再生", "再開", "play", "resume", "continue", "再生して"],
39
+ },
40
+ ];
41
+
42
+
43
+ export const DISCOVERY_COMMANDS = [
44
+ {
45
+ command: "/spotify:share",
46
+ description: "Now Playing を X に投稿(API 設定時)または共有画面を開く",
47
+ userPhrases: ["シェア", "Xに投稿", "post to X", "tweet", "share this song"],
48
+ },
49
+ {
50
+ command: "/spotify:play-uri",
51
+ description: "Spotify 曲 URL / URI を指定して再生",
52
+ userPhrases: ["この曲再生", "play this link", "play uri"],
53
+ },
54
+ ] as const;
55
+
56
+ export function playbackCommandById(id: string): PlaybackCommandSpec | undefined {
57
+ const normalized = id.trim().toLowerCase();
58
+ return PLAYBACK_COMMANDS.find((entry) => entry.id === normalized);
59
+ }
60
+
61
+ export function formatPlaybackHelpMarkdown(): string {
62
+ const rows = PLAYBACK_COMMANDS.map((entry) => {
63
+ const phrases = entry.userPhrases.slice(0, 4).join(" · ");
64
+ return `| ${entry.command} | ${entry.description} | ${phrases} |`;
65
+ });
66
+
67
+ return [
68
+ "### Spotify playback controls",
69
+ "",
70
+ "ユーザーが曲操作や再生/停止を依頼したら、下表から意図に最も近い **Pi コマンド** を1つ選んで実行してください。",
71
+ "推測が曖昧なときは `/spotify:help` の一覧を参照するか、ユーザーに確認してください。",
72
+ "",
73
+ "| Command | Action | Example user phrases |",
74
+ "|---|---|---|",
75
+ ...rows,
76
+ "",
77
+ "Discovery / share:",
78
+ ...DISCOVERY_COMMANDS.map((entry) => `| ${entry.command} | ${entry.description} | ${entry.userPhrases.slice(0, 4).join(" · ")} |`),
79
+ "",
80
+ "Other useful commands:",
81
+ "- `/spotify:refresh` — Now Playing を即時更新",
82
+ "- `/spotify:status` — 認証状態(token 値は出さない)",
83
+ "",
84
+ "Note: playback control requires `user-modify-playback-state` scope. If controls fail after an upgrade, run `/spotify:login` again.",
85
+ ].join("\n");
86
+ }
87
+
88
+ export function formatPlaybackHelpText(): string {
89
+ return PLAYBACK_COMMANDS.map((entry) => {
90
+ const phrases = entry.userPhrases.join(", ");
91
+ return `${entry.command} — ${entry.description} (${phrases})`;
92
+ }).join("\n");
93
+ }
@@ -0,0 +1,86 @@
1
+ export interface PlaybackSnapshot {
2
+ track: string;
3
+ artist: string;
4
+ isPlaying: boolean;
5
+ progressMs?: number;
6
+ durationMs?: number;
7
+ trackId?: string;
8
+ spotifyUrl?: string;
9
+ artistIds?: string[];
10
+ fetchedAt: number;
11
+ }
12
+
13
+ interface SpotifyArtist {
14
+ id?: string;
15
+ name?: string;
16
+ }
17
+
18
+ interface SpotifyTrackItem {
19
+ id?: string;
20
+ name?: string;
21
+ duration_ms?: number;
22
+ external_urls?: { spotify?: string };
23
+ artists?: SpotifyArtist[];
24
+ }
25
+
26
+ export interface SpotifyCurrentlyPlayingRaw {
27
+ is_playing?: boolean;
28
+ progress_ms?: number;
29
+ item?: SpotifyTrackItem | null;
30
+ }
31
+
32
+ function artistNames(item: SpotifyTrackItem | null | undefined): string {
33
+ const artists = Array.isArray(item?.artists)
34
+ ? item!.artists!
35
+ .map((artist) => (typeof artist?.name === "string" ? artist.name.trim() : ""))
36
+ .filter(Boolean)
37
+ : [];
38
+ return artists.join(", ") || "Unknown artist";
39
+ }
40
+
41
+ function artistIds(item: SpotifyTrackItem | null | undefined): string[] {
42
+ if (!Array.isArray(item?.artists)) return [];
43
+ return item.artists
44
+ .map((artist) => (typeof artist?.id === "string" ? artist.id.trim() : ""))
45
+ .filter(Boolean);
46
+ }
47
+
48
+ export function toPlaybackSnapshot(
49
+ raw: SpotifyCurrentlyPlayingRaw | null | undefined,
50
+ fetchedAt = Date.now(),
51
+ ): PlaybackSnapshot | null {
52
+ if (!raw?.item) return null;
53
+
54
+ const track = typeof raw.item.name === "string" ? raw.item.name.trim() : "";
55
+ if (!track) return null;
56
+
57
+ const snapshot: PlaybackSnapshot = {
58
+ track,
59
+ artist: artistNames(raw.item),
60
+ isPlaying: Boolean(raw.is_playing),
61
+ fetchedAt,
62
+ };
63
+
64
+ if (typeof raw.progress_ms === "number" && Number.isFinite(raw.progress_ms)) {
65
+ snapshot.progressMs = raw.progress_ms;
66
+ }
67
+ if (typeof raw.item.duration_ms === "number" && Number.isFinite(raw.item.duration_ms)) {
68
+ snapshot.durationMs = raw.item.duration_ms;
69
+ }
70
+ if (typeof raw.item.id === "string" && raw.item.id.trim()) {
71
+ snapshot.trackId = raw.item.id.trim();
72
+ }
73
+ if (typeof raw.item.external_urls?.spotify === "string" && raw.item.external_urls.spotify.trim()) {
74
+ snapshot.spotifyUrl = raw.item.external_urls.spotify.trim();
75
+ } else if (snapshot.trackId) {
76
+ snapshot.spotifyUrl = `https://open.spotify.com/track/${snapshot.trackId}`;
77
+ }
78
+ const ids = artistIds(raw.item);
79
+ if (ids.length > 0) snapshot.artistIds = ids;
80
+
81
+ return snapshot;
82
+ }
83
+
84
+ export const __testing = {
85
+ toPlaybackSnapshot,
86
+ };