@audiofool/moondog 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/README.md +125 -0
- package/assets/brand/moondog-lunar-record/moondog-logo-1x1.png +0 -0
- package/contracts/v1/capability-policy.schema.json +572 -0
- package/contracts/v1/definitions.schema.json +365 -0
- package/contracts/v1/library-track-observation.schema.json +138 -0
- package/contracts/v1/listening-event.schema.json +111 -0
- package/contracts/v1/profile-evidence.schema.json +237 -0
- package/contracts/v1/taste-event.schema.json +258 -0
- package/contracts/v1/tool-invocation.schema.json +318 -0
- package/contracts/v1/tool-receipt.schema.json +1066 -0
- package/contracts/v1/track-ref.schema.json +275 -0
- package/contracts/v2/profile-evidence.schema.json +284 -0
- package/package.json +105 -0
- package/scripts/contract-lib.mjs +361 -0
- package/scripts/contract-semantics.mjs +2630 -0
- package/scripts/demo-studio.mjs +58 -0
- package/scripts/moondog +52 -0
- package/scripts/moondog.mjs +954 -0
- package/src/core/apple-data-migration.mjs +50 -0
- package/src/core/apple-library-source-status.mjs +220 -0
- package/src/core/apple-projection-domain-services.mjs +2356 -0
- package/src/core/capability-catalog.mjs +577 -0
- package/src/core/listening-profile-domain-services.mjs +1010 -0
- package/src/core/lyric-library.mjs +121 -0
- package/src/core/lyric-profile.mjs +50 -0
- package/src/core/lyric-service.mjs +41 -0
- package/src/core/moondog-application.mjs +1827 -0
- package/src/core/state-directory.mjs +19 -0
- package/src/core/synthetic-domain-services.mjs +1485 -0
- package/src/core/uuid-v5.mjs +42 -0
- package/src/demo/fictional-spotify-history.mjs +400 -0
- package/src/demo/moondog-demo.mjs +355 -0
- package/src/demo/moondog-interactive-tasteprint-demo.mjs +293 -0
- package/src/demo/moondog-tasteprint-demo.mjs +631 -0
- package/src/importers/apple-music-library/import-batch.mjs +290 -0
- package/src/importers/apple-music-library/index.mjs +76 -0
- package/src/importers/apple-music-library/normalize.mjs +1126 -0
- package/src/importers/apple-music-library/parse-plist.mjs +357 -0
- package/src/importers/apple-music-library/projection-records.mjs +557 -0
- package/src/importers/apple-music-library/read-batch.mjs +276 -0
- package/src/importers/apple-music-library/sqlite-projection.mjs +1307 -0
- package/src/importers/apple-music-library/stable-ids.mjs +19 -0
- package/src/importers/apple-music-library/track-snapshot.schema.json +136 -0
- package/src/integrations/apple-music/catalog.mjs +770 -0
- package/src/integrations/cross-catalog-artist-identity.mjs +222 -0
- package/src/integrations/listenbrainz/history-file.mjs +560 -0
- package/src/integrations/lyrics/lrclib.mjs +55 -0
- package/src/integrations/open-music-similarity/artist-radio.mjs +806 -0
- package/src/integrations/public-playlists.mjs +104 -0
- package/src/integrations/spotify/account-data-history.mjs +545 -0
- package/src/integrations/spotify/account-data-profile.mjs +918 -0
- package/src/integrations/spotify/authentication.mjs +791 -0
- package/src/integrations/spotify/catalog-resolver.mjs +657 -0
- package/src/integrations/spotify/connection.mjs +125 -0
- package/src/integrations/spotify/credential-store.mjs +26 -0
- package/src/integrations/spotify/extended-streaming-history.mjs +525 -0
- package/src/integrations/spotify/history-archive.mjs +59 -0
- package/src/integrations/spotify/index.mjs +80 -0
- package/src/integrations/spotify/recent-activity.mjs +198 -0
- package/src/integrations/spotify/resolution-cache.mjs +260 -0
- package/src/integrations/spotify/service.mjs +713 -0
- package/src/integrations/spotify/settings.mjs +89 -0
- package/src/integrations/spotify/web-api-client.mjs +761 -0
- package/src/integrations/web/codex-web.mjs +263 -0
- package/src/integrations/youtube-music/takeout.mjs +143 -0
- package/src/memory/local-memory-store.mjs +1705 -0
- package/src/memory/reflection-worker.mjs +87 -0
- package/src/profile/apple-library-import.mjs +28 -0
- package/src/profile/history-import.mjs +163 -0
- package/src/profile/listener-corrections.mjs +217 -0
- package/src/profile/listening-history-store.mjs +2055 -0
- package/src/profile/listening-profile-projection.mjs +2520 -0
- package/src/profile/local-music-data.mjs +812 -0
- package/src/profile/music-import-bundle.mjs +91 -0
- package/src/profile/music-providers.mjs +25 -0
- package/src/profile/spotify-archive-catalog-hints.mjs +210 -0
- package/src/profile/spotify-archive-taste.mjs +482 -0
- package/src/runtime/pi/agent-runtime.mjs +6444 -0
- package/src/runtime/pi/authentication.mjs +104 -0
- package/src/runtime/pi/configured-runtime.mjs +98 -0
- package/src/runtime/pi/memory-agent-runtime.mjs +337 -0
- package/src/runtime/pi/model-catalog.mjs +62 -0
- package/src/runtime/pi/model-transport.mjs +117 -0
- package/src/runtime/pi/persistent-credential-store.mjs +670 -0
- package/src/runtime/pi/provider-registry.mjs +74 -0
- package/src/runtime/pi/runtime-settings.mjs +81 -0
- package/src/surfaces/cli/auth-command.mjs +355 -0
- package/src/surfaces/cli/brand-components.mjs +390 -0
- package/src/surfaces/cli/brand-theme.mjs +231 -0
- package/src/surfaces/cli/catalog-command.mjs +509 -0
- package/src/surfaces/cli/command-completions.mjs +113 -0
- package/src/surfaces/cli/data-command.mjs +214 -0
- package/src/surfaces/cli/format-output.mjs +750 -0
- package/src/surfaces/cli/history-import-view.mjs +445 -0
- package/src/surfaces/cli/import-guides.mjs +9 -0
- package/src/surfaces/cli/listenbrainz-command.mjs +129 -0
- package/src/surfaces/cli/listening-menu.mjs +171 -0
- package/src/surfaces/cli/profile-command.mjs +290 -0
- package/src/surfaces/cli/spotify-command.mjs +849 -0
- package/src/surfaces/cli/taste-profile-model.mjs +306 -0
- package/src/surfaces/cli/taste-profile-view.mjs +272 -0
- package/src/surfaces/cli/terminal-art.mjs +295 -0
- package/src/surfaces/cli/tui.mjs +1714 -0
- package/src/surfaces/cli/web-command.mjs +52 -0
- package/src/surfaces/html/tasteprint-artifact.mjs +96 -0
- package/src/surfaces/html/tasteprint.mjs +3575 -0
- package/src/surfaces/web/showcase.mjs +272 -0
- package/src/surfaces/web/studio-page.mjs +4148 -0
- package/src/surfaces/web/studio.mjs +2288 -0
|
@@ -0,0 +1,849 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SPOTIFY_DEFAULT_REDIRECT_URI,
|
|
3
|
+
SPOTIFY_DEFAULT_SCOPES,
|
|
4
|
+
createSpotifyAuthentication,
|
|
5
|
+
} from "../../integrations/spotify/authentication.mjs";
|
|
6
|
+
import { createSpotifyCredentialStore } from "../../integrations/spotify/credential-store.mjs";
|
|
7
|
+
import {
|
|
8
|
+
readSpotifyConfiguration,
|
|
9
|
+
writeSpotifyConfiguration,
|
|
10
|
+
} from "../../integrations/spotify/settings.mjs";
|
|
11
|
+
import { createSpotifyService } from "../../integrations/spotify/service.mjs";
|
|
12
|
+
import { createSpotifyWebApiClient } from "../../integrations/spotify/web-api-client.mjs";
|
|
13
|
+
import { createSpotifyCatalogResolver } from "../../integrations/spotify/catalog-resolver.mjs";
|
|
14
|
+
import {
|
|
15
|
+
SPOTIFY_RECENT_ACTIVITY_SOURCE,
|
|
16
|
+
projectSpotifyRecentActivity,
|
|
17
|
+
} from "../../integrations/spotify/recent-activity.mjs";
|
|
18
|
+
import { readSpotifyHistoryArchive } from "../../integrations/spotify/history-archive.mjs";
|
|
19
|
+
import { sanitizeTerminalText } from "./format-output.mjs";
|
|
20
|
+
|
|
21
|
+
const readCommands = new Set(["account", "now", "devices", "queue", "recent"]);
|
|
22
|
+
const writeCommands = new Set([
|
|
23
|
+
"play",
|
|
24
|
+
"pause",
|
|
25
|
+
"next",
|
|
26
|
+
"previous",
|
|
27
|
+
"volume",
|
|
28
|
+
"seek",
|
|
29
|
+
"shuffle",
|
|
30
|
+
"repeat",
|
|
31
|
+
"transfer",
|
|
32
|
+
"queue-add",
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
export function spotifyHelpText() {
|
|
36
|
+
return `# Moondog Spotify
|
|
37
|
+
|
|
38
|
+
One-time setup:
|
|
39
|
+
|
|
40
|
+
1. Create a Spotify developer app.
|
|
41
|
+
2. Add this exact redirect URI: \`${SPOTIFY_DEFAULT_REDIRECT_URI}\`.
|
|
42
|
+
3. Run \`moondog spotify configure <client-id>\`.
|
|
43
|
+
4. Run \`moondog spotify login\`.
|
|
44
|
+
|
|
45
|
+
Commands:
|
|
46
|
+
|
|
47
|
+
- \`moondog spotify login [client-id] [--history-only]\`
|
|
48
|
+
- \`moondog spotify status [--json]\`
|
|
49
|
+
- \`moondog spotify logout [--json]\`
|
|
50
|
+
- \`moondog spotify account|now|devices|queue [--json]\`
|
|
51
|
+
- \`moondog spotify recent [--limit <1-50>] [--after <ms>|--before <ms>] [--json]\`
|
|
52
|
+
- \`moondog spotify sync-recent [--limit <1-50>] [--json]\`
|
|
53
|
+
- \`moondog spotify import-history <spotify-history.zip> [--json]\`
|
|
54
|
+
- \`moondog spotify play [spotify-uri] [--json]\`
|
|
55
|
+
- \`moondog spotify pause|next|previous [--json]\`
|
|
56
|
+
- \`moondog spotify volume <0-100> [--json]\`
|
|
57
|
+
- \`moondog spotify seek <milliseconds> [--json]\`
|
|
58
|
+
- \`moondog spotify shuffle on|off [--json]\`
|
|
59
|
+
- \`moondog spotify repeat off|track|context [--json]\`
|
|
60
|
+
- \`moondog spotify transfer <device-id> [--play] [--json]\`
|
|
61
|
+
- \`moondog spotify queue-add <track-or-episode-uri> [device-id] [--json]\`
|
|
62
|
+
- \`moondog spotify resolve --title <title> --artist <artist> [--release <release>] [--duration-ms <ms>] [--json]\`
|
|
63
|
+
|
|
64
|
+
Playback control requires Spotify Premium and an available Spotify Connect device.`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function writeLine(stream, value = "") {
|
|
68
|
+
stream.write(`${sanitizeTerminalText(value)}\n`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function commandError(message) {
|
|
72
|
+
const error = new Error(message);
|
|
73
|
+
error.code = "spotify_command_invalid";
|
|
74
|
+
return error;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function parseArguments(args, inheritedJson) {
|
|
78
|
+
const values = [...(args ?? [])];
|
|
79
|
+
const jsonIndexes = values
|
|
80
|
+
.map((value, index) => (value === "--json" ? index : -1))
|
|
81
|
+
.filter((index) => index !== -1);
|
|
82
|
+
if (jsonIndexes.length > 1 || (jsonIndexes.length === 1 && jsonIndexes[0] !== values.length - 1)) {
|
|
83
|
+
throw commandError("The --json option must appear once at the end of the Spotify command.");
|
|
84
|
+
}
|
|
85
|
+
const json = Boolean(inheritedJson || jsonIndexes.length === 1);
|
|
86
|
+
if (jsonIndexes.length === 1) values.pop();
|
|
87
|
+
const action = values[0];
|
|
88
|
+
const rest = values.slice(1);
|
|
89
|
+
if (action === "-h" || action === "--help") return { action: "help", rest, json };
|
|
90
|
+
return { action, rest, json };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function exactArguments(action, rest, count, usage) {
|
|
94
|
+
if (rest.length !== count) {
|
|
95
|
+
throw commandError(`Usage: moondog spotify ${usage ?? action}.`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function integerArgument(value, { label, minimum = 0, maximum }) {
|
|
100
|
+
if (typeof value !== "string" || !/^(?:0|[1-9][0-9]*)$/u.test(value)) {
|
|
101
|
+
throw commandError(`${label} must be an integer from ${minimum} to ${maximum}.`);
|
|
102
|
+
}
|
|
103
|
+
const number = Number(value);
|
|
104
|
+
if (!Number.isSafeInteger(number) || number < minimum || number > maximum) {
|
|
105
|
+
throw commandError(`${label} must be an integer from ${minimum} to ${maximum}.`);
|
|
106
|
+
}
|
|
107
|
+
return number;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function parseResolveFlags(action, rest) {
|
|
111
|
+
const flags = {};
|
|
112
|
+
for (let index = 0; index < rest.length; index += 2) {
|
|
113
|
+
const flag = rest[index];
|
|
114
|
+
const value = rest[index + 1];
|
|
115
|
+
if (flag !== "--title" && flag !== "--artist" && flag !== "--release" && flag !== "--duration-ms") {
|
|
116
|
+
throw commandError(
|
|
117
|
+
"Usage: moondog spotify resolve --title <title> --artist <artist> [--release <release>] [--duration-ms <milliseconds>].",
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
121
|
+
throw commandError(`The ${flag} option requires a value.`);
|
|
122
|
+
}
|
|
123
|
+
if (flags[flag] !== undefined) {
|
|
124
|
+
throw commandError(`The ${flag} option must appear once.`);
|
|
125
|
+
}
|
|
126
|
+
flags[flag] = value;
|
|
127
|
+
}
|
|
128
|
+
if (!flags["--title"] || !flags["--artist"]) {
|
|
129
|
+
throw commandError(
|
|
130
|
+
"Usage: moondog spotify resolve --title <title> --artist <artist> [--release <release>] [--duration-ms <milliseconds>].",
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
title: flags["--title"],
|
|
135
|
+
artist: flags["--artist"],
|
|
136
|
+
release: flags["--release"] ?? "Unknown release",
|
|
137
|
+
...(flags["--duration-ms"] !== undefined
|
|
138
|
+
? {
|
|
139
|
+
durationMs: integerArgument(flags["--duration-ms"], {
|
|
140
|
+
label: "--duration-ms",
|
|
141
|
+
maximum: 86_400_000,
|
|
142
|
+
}),
|
|
143
|
+
}
|
|
144
|
+
: {}),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function parseRecentFlags(action, rest, { cursors = true } = {}) {
|
|
149
|
+
if (rest.length % 2 !== 0) {
|
|
150
|
+
throw commandError(
|
|
151
|
+
`Usage: moondog spotify ${action} [--limit <1-50>]${cursors ? " [--after <ms>|--before <ms>]" : ""}.`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
const input = {};
|
|
155
|
+
for (let index = 0; index < rest.length; index += 2) {
|
|
156
|
+
const flag = rest[index];
|
|
157
|
+
const value = rest[index + 1];
|
|
158
|
+
const allowed = cursors
|
|
159
|
+
? new Set(["--limit", "--after", "--before"])
|
|
160
|
+
: new Set(["--limit"]);
|
|
161
|
+
if (!allowed.has(flag) || input[flag] !== undefined) {
|
|
162
|
+
throw commandError(
|
|
163
|
+
`Usage: moondog spotify ${action} [--limit <1-50>]${cursors ? " [--after <ms>|--before <ms>]" : ""}.`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
input[flag] = integerArgument(value, {
|
|
167
|
+
label: flag,
|
|
168
|
+
minimum: flag === "--limit" ? 1 : 0,
|
|
169
|
+
maximum: flag === "--limit" ? 50 : Number.MAX_SAFE_INTEGER,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
if (input["--after"] !== undefined && input["--before"] !== undefined) {
|
|
173
|
+
throw commandError("Choose either --after or --before for Spotify recent activity.");
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
...(input["--limit"] !== undefined ? { limit: input["--limit"] } : {}),
|
|
177
|
+
...(input["--after"] !== undefined ? { after: input["--after"] } : {}),
|
|
178
|
+
...(input["--before"] !== undefined ? { before: input["--before"] } : {}),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function publicAuthStatus(status, clientIdConfigured, requiredScopes = SPOTIFY_DEFAULT_SCOPES) {
|
|
183
|
+
const stored = status?.state === "stored";
|
|
184
|
+
const result = {
|
|
185
|
+
provider: "spotify",
|
|
186
|
+
state: stored ? "stored" : "not_configured",
|
|
187
|
+
client_id_configured: clientIdConfigured,
|
|
188
|
+
type: stored && status?.type === "oauth" ? "oauth" : null,
|
|
189
|
+
access_expires_at:
|
|
190
|
+
stored && Number.isSafeInteger(status?.accessExpiresAt)
|
|
191
|
+
? status.accessExpiresAt
|
|
192
|
+
: null,
|
|
193
|
+
refresh_expires_at:
|
|
194
|
+
stored && Number.isSafeInteger(status?.refreshExpiresAt)
|
|
195
|
+
? status.refreshExpiresAt
|
|
196
|
+
: null,
|
|
197
|
+
redirect_uri: SPOTIFY_DEFAULT_REDIRECT_URI,
|
|
198
|
+
};
|
|
199
|
+
if (stored) {
|
|
200
|
+
const grantedScopes =
|
|
201
|
+
typeof status?.scopes === "string"
|
|
202
|
+
? status.scopes.split(/\s+/u).filter(Boolean).sort()
|
|
203
|
+
: [];
|
|
204
|
+
const missingScopes = requiredScopes.filter(
|
|
205
|
+
(scope) => !grantedScopes.includes(scope),
|
|
206
|
+
);
|
|
207
|
+
result.scopes_sufficient = missingScopes.length === 0;
|
|
208
|
+
result.missing_scopes = missingScopes;
|
|
209
|
+
}
|
|
210
|
+
return result;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function actionResult(receipt, fallbackAction) {
|
|
214
|
+
return {
|
|
215
|
+
provider: "spotify",
|
|
216
|
+
ok: receipt?.ok === true,
|
|
217
|
+
effect: "write_external",
|
|
218
|
+
action:
|
|
219
|
+
typeof receipt?.action === "string" && receipt.action.length > 0
|
|
220
|
+
? receipt.action
|
|
221
|
+
: fallbackAction,
|
|
222
|
+
state: receipt?.state === "accepted" ? "accepted" : "unknown",
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function withoutSecretFields(value, seen = new WeakSet()) {
|
|
227
|
+
if (Array.isArray(value)) return value.map((entry) => withoutSecretFields(entry, seen));
|
|
228
|
+
if (!value || typeof value !== "object") return value;
|
|
229
|
+
if (seen.has(value)) return null;
|
|
230
|
+
seen.add(value);
|
|
231
|
+
const result = {};
|
|
232
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
233
|
+
if (/(?:authorization|credential|secret|token)/iu.test(key)) continue;
|
|
234
|
+
result[key] = withoutSecretFields(entry, seen);
|
|
235
|
+
}
|
|
236
|
+
return result;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function itemLabel(item) {
|
|
240
|
+
if (!item || typeof item !== "object") return "Unknown item";
|
|
241
|
+
const name = typeof item.name === "string" ? item.name : "Unknown item";
|
|
242
|
+
const artists = Array.isArray(item.artists) ? item.artists.filter((value) => typeof value === "string") : [];
|
|
243
|
+
return artists.length > 0 ? `${name} - ${artists.join(", ")}` : name;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function formatPlain(action, value) {
|
|
247
|
+
const plays = (count) => `${count ?? 0} ${count === 1 ? "play" : "plays"}`;
|
|
248
|
+
const day = (timestamp) => typeof timestamp === "string" ? timestamp.slice(0, 10) : "an unknown date";
|
|
249
|
+
if (action === "configure") {
|
|
250
|
+
return [
|
|
251
|
+
"Spotify app saved.",
|
|
252
|
+
`Redirect URI: ${SPOTIFY_DEFAULT_REDIRECT_URI}`,
|
|
253
|
+
"Next: moondog spotify login",
|
|
254
|
+
].join("\n");
|
|
255
|
+
}
|
|
256
|
+
if (["status", "login", "logout"].includes(action)) {
|
|
257
|
+
const lines = [
|
|
258
|
+
`Spotify app: ${value.client_id_configured ? "saved" : "not set up yet"}`,
|
|
259
|
+
`Signed in: ${value.state === "stored" ? "yes" : "no"}`,
|
|
260
|
+
`Redirect URI: ${SPOTIFY_DEFAULT_REDIRECT_URI}`,
|
|
261
|
+
];
|
|
262
|
+
if (value.state === "stored") {
|
|
263
|
+
const purpose = value.scope_purpose === "recent_listening" ? "reading recent plays" : "playback and playlists";
|
|
264
|
+
lines.push(
|
|
265
|
+
value.scopes_sufficient
|
|
266
|
+
? `Permissions for ${purpose}: all set`
|
|
267
|
+
: `Permissions for ${purpose}: sign in again to allow ${value.missing_scopes.join(", ")}`,
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
return lines.join("\n");
|
|
271
|
+
}
|
|
272
|
+
if (action === "resolve") {
|
|
273
|
+
const resolutions = Array.isArray(value.resolutions)
|
|
274
|
+
? value.resolutions
|
|
275
|
+
: [];
|
|
276
|
+
const lines = [
|
|
277
|
+
`Found ${value.resolved_count ?? 0} of ${value.requested ?? resolutions.length} on Spotify`,
|
|
278
|
+
];
|
|
279
|
+
for (const resolution of resolutions) {
|
|
280
|
+
if (resolution.status !== "resolved") {
|
|
281
|
+
lines.push(`- ${resolution.track_ref_id}: not on Spotify`);
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
const artists = Array.isArray(resolution.matched?.artists)
|
|
285
|
+
? resolution.matched.artists.join(", ")
|
|
286
|
+
: "Unknown artist";
|
|
287
|
+
lines.push(
|
|
288
|
+
`- ${resolution.track_ref_id}: ${resolution.matched?.title ?? "Unknown track"} - ${artists} (${resolution.match_quality ?? "standard"} match)`,
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
return lines.join("\n");
|
|
292
|
+
}
|
|
293
|
+
if (writeCommands.has(action)) {
|
|
294
|
+
return `Spotify: ${value.action}, done.`;
|
|
295
|
+
}
|
|
296
|
+
if (action === "account") {
|
|
297
|
+
const lines = ["# Your Spotify account"];
|
|
298
|
+
if (value.display_name) lines.push(`- Name: ${value.display_name}`);
|
|
299
|
+
if (value.account_id) lines.push(`- ID: ${value.account_id}`);
|
|
300
|
+
if (value.product) lines.push(`- Plan: ${value.product}`);
|
|
301
|
+
if (value.country) lines.push(`- Country: ${value.country}`);
|
|
302
|
+
return lines.join("\n");
|
|
303
|
+
}
|
|
304
|
+
if (action === "now") {
|
|
305
|
+
if (value.state !== "available") return "Nothing is playing on Spotify.";
|
|
306
|
+
const lines = [
|
|
307
|
+
`${value.is_playing ? "Playing" : "Paused"}: ${itemLabel(value.item)}`,
|
|
308
|
+
];
|
|
309
|
+
if (value.device?.name) lines.push(`On: ${value.device.name} (${value.device.id})`);
|
|
310
|
+
if (Number.isInteger(value.progress_ms)) {
|
|
311
|
+
const seconds = Math.floor(value.progress_ms / 1000);
|
|
312
|
+
lines.push(`At: ${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`);
|
|
313
|
+
}
|
|
314
|
+
return lines.join("\n");
|
|
315
|
+
}
|
|
316
|
+
if (action === "devices") {
|
|
317
|
+
const devices = Array.isArray(value.devices) ? value.devices : [];
|
|
318
|
+
if (devices.length === 0) return "No Spotify devices are available. Open Spotify somewhere first.";
|
|
319
|
+
return [
|
|
320
|
+
"# Your Spotify devices",
|
|
321
|
+
...devices.map(
|
|
322
|
+
(device) =>
|
|
323
|
+
`- ${device.is_active ? "*" : "-"} ${device.name} (${device.type}) - ${device.id}`,
|
|
324
|
+
),
|
|
325
|
+
].join("\n");
|
|
326
|
+
}
|
|
327
|
+
if (action === "queue") {
|
|
328
|
+
const queue = Array.isArray(value.queue) ? value.queue : [];
|
|
329
|
+
const lines = ["# Up next on Spotify"];
|
|
330
|
+
if (value.currently_playing) lines.push(`Now: ${itemLabel(value.currently_playing)}`);
|
|
331
|
+
if (queue.length === 0) lines.push("Nothing queued.");
|
|
332
|
+
else lines.push(...queue.map((item, index) => `${index + 1}. ${itemLabel(item)}`));
|
|
333
|
+
return lines.join("\n");
|
|
334
|
+
}
|
|
335
|
+
if (action === "recent") {
|
|
336
|
+
const items = Array.isArray(value.items) ? value.items : [];
|
|
337
|
+
if (items.length === 0) return "Spotify didn't return any recent plays.";
|
|
338
|
+
return [
|
|
339
|
+
`# What you played lately (${items.length})`,
|
|
340
|
+
...items.map(
|
|
341
|
+
(item) =>
|
|
342
|
+
`- ${item.played_at}: ${itemLabel(item.track)}${item.context_type ? ` [${item.context_type}]` : ""}`,
|
|
343
|
+
),
|
|
344
|
+
].join("\n");
|
|
345
|
+
}
|
|
346
|
+
if (action === "sync-recent") {
|
|
347
|
+
return [
|
|
348
|
+
"# Your latest plays are in",
|
|
349
|
+
`- ${plays(value.fetched_events)} from Spotify`,
|
|
350
|
+
`- ${value.inserted_events ?? 0} new, ${value.duplicate_events ?? 0} I already had`,
|
|
351
|
+
`- ${value.inserted_track_refs ?? 0} songs new to your profile`,
|
|
352
|
+
].join("\n");
|
|
353
|
+
}
|
|
354
|
+
if (action === "import-history") {
|
|
355
|
+
const extended =
|
|
356
|
+
value.data_scope === "lifetime_extended_streaming_history";
|
|
357
|
+
const lines = [
|
|
358
|
+
extended ? "# Your Spotify history is in" : "# Your Spotify account data is in",
|
|
359
|
+
`- ${plays(value.input_records)} in the file, from ${day(value.earliest_occurred_at)} to ${day(value.latest_occurred_at)}`,
|
|
360
|
+
value.already_imported
|
|
361
|
+
? "- You imported this file before, so nothing new was added"
|
|
362
|
+
: `- ${value.inserted_events ?? 0} new${value.duplicate_events ? `, ${value.duplicate_events} I already had` : ""}`,
|
|
363
|
+
];
|
|
364
|
+
if (value.superseded_events > 0) {
|
|
365
|
+
lines.push(`- ${plays(value.superseded_events)} you already had were replaced with more detailed versions`);
|
|
366
|
+
}
|
|
367
|
+
if (value.inserted_track_refs > 0) lines.push(`- ${value.inserted_track_refs} songs new to your profile`);
|
|
368
|
+
if (!extended && value.inserted_profile_evidence > 0) {
|
|
369
|
+
lines.push(`- ${value.inserted_profile_evidence} saved songs, follows, and playlist entries`);
|
|
370
|
+
}
|
|
371
|
+
if (!extended) lines.push("- This format doesn't include listening time. Extended streaming history does.");
|
|
372
|
+
lines.push(`- File fingerprint: ${value.archive_sha256 ?? "unavailable"}`);
|
|
373
|
+
return lines.join("\n");
|
|
374
|
+
}
|
|
375
|
+
return JSON.stringify(value, null, 2);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function emit(output, action, value, json) {
|
|
379
|
+
const safeValue = withoutSecretFields(value);
|
|
380
|
+
writeLine(output, json ? JSON.stringify(safeValue, null, 2) : formatPlain(action, safeValue));
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function knownSpotifyError(error) {
|
|
384
|
+
return (
|
|
385
|
+
typeof error?.code === "string" &&
|
|
386
|
+
(error.code.startsWith("spotify_") || error.code.startsWith("invalid_"))
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async function externalCall(operation, safeFailureMessage) {
|
|
391
|
+
try {
|
|
392
|
+
return await operation();
|
|
393
|
+
} catch (error) {
|
|
394
|
+
if (knownSpotifyError(error)) throw error;
|
|
395
|
+
const wrapped = new Error(safeFailureMessage);
|
|
396
|
+
wrapped.code = "spotify_command_failed";
|
|
397
|
+
throw wrapped;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function createRuntimeResolver({
|
|
402
|
+
environment,
|
|
403
|
+
fetchImpl,
|
|
404
|
+
openBrowser,
|
|
405
|
+
credentialStore,
|
|
406
|
+
spotify,
|
|
407
|
+
historyOnly = false,
|
|
408
|
+
}) {
|
|
409
|
+
let scopedStore;
|
|
410
|
+
let configuration;
|
|
411
|
+
let authentication;
|
|
412
|
+
let client;
|
|
413
|
+
let service;
|
|
414
|
+
|
|
415
|
+
const resolveStore = () => {
|
|
416
|
+
if (!scopedStore) {
|
|
417
|
+
scopedStore = credentialStore ?? createSpotifyCredentialStore({ environment });
|
|
418
|
+
}
|
|
419
|
+
return scopedStore;
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
const readConfiguration = async () => {
|
|
423
|
+
if (configuration !== undefined) return configuration;
|
|
424
|
+
if (spotify && Object.hasOwn(spotify, "configuration")) {
|
|
425
|
+
configuration =
|
|
426
|
+
typeof spotify.configuration === "function"
|
|
427
|
+
? await spotify.configuration()
|
|
428
|
+
: spotify.configuration;
|
|
429
|
+
} else {
|
|
430
|
+
configuration = await readSpotifyConfiguration(environment);
|
|
431
|
+
}
|
|
432
|
+
return configuration;
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
return {
|
|
436
|
+
async configure(clientId) {
|
|
437
|
+
const value = spotify?.configure
|
|
438
|
+
? await spotify.configure(clientId)
|
|
439
|
+
: await writeSpotifyConfiguration(clientId, environment);
|
|
440
|
+
configuration = value;
|
|
441
|
+
return value;
|
|
442
|
+
},
|
|
443
|
+
|
|
444
|
+
async configuration() {
|
|
445
|
+
return readConfiguration();
|
|
446
|
+
},
|
|
447
|
+
|
|
448
|
+
async authentication({ required = true } = {}) {
|
|
449
|
+
if (authentication) return authentication;
|
|
450
|
+
if (spotify?.authentication) {
|
|
451
|
+
authentication = spotify.authentication;
|
|
452
|
+
return authentication;
|
|
453
|
+
}
|
|
454
|
+
const current = await readConfiguration();
|
|
455
|
+
if (!current) {
|
|
456
|
+
if (!required) return undefined;
|
|
457
|
+
throw commandError(
|
|
458
|
+
"Spotify is not configured. Run moondog spotify configure <client-id> first.",
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
authentication = createSpotifyAuthentication({
|
|
462
|
+
clientId: current.clientId,
|
|
463
|
+
...(historyOnly ? { scopes: ["user-read-recently-played"] } : {}),
|
|
464
|
+
credentialStore: resolveStore(),
|
|
465
|
+
fetchImpl,
|
|
466
|
+
...(openBrowser ? { openBrowser } : {}),
|
|
467
|
+
});
|
|
468
|
+
return authentication;
|
|
469
|
+
},
|
|
470
|
+
|
|
471
|
+
async webClient() {
|
|
472
|
+
if (client) return client;
|
|
473
|
+
if (spotify?.service) {
|
|
474
|
+
throw commandError(
|
|
475
|
+
"The catalog resolver is unavailable in this embedded Spotify runtime.",
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
const auth = await this.authentication();
|
|
479
|
+
client = createSpotifyWebApiClient({
|
|
480
|
+
fetchImpl,
|
|
481
|
+
tokenProvider: () => auth.getAccessToken(),
|
|
482
|
+
});
|
|
483
|
+
return client;
|
|
484
|
+
},
|
|
485
|
+
|
|
486
|
+
async resolver(resolutionCache) {
|
|
487
|
+
if (spotify?.resolver) return spotify.resolver;
|
|
488
|
+
return createSpotifyCatalogResolver({
|
|
489
|
+
client: await this.webClient(),
|
|
490
|
+
...(resolutionCache ? { cache: resolutionCache } : {}),
|
|
491
|
+
});
|
|
492
|
+
},
|
|
493
|
+
|
|
494
|
+
async service() {
|
|
495
|
+
if (service) return service;
|
|
496
|
+
if (spotify?.service) {
|
|
497
|
+
service = spotify.service;
|
|
498
|
+
return service;
|
|
499
|
+
}
|
|
500
|
+
service = createSpotifyService({ client: await this.webClient() });
|
|
501
|
+
return service;
|
|
502
|
+
},
|
|
503
|
+
|
|
504
|
+
async deleteCredentialWithoutConfiguration() {
|
|
505
|
+
await resolveStore().delete();
|
|
506
|
+
},
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
async function runReadCommand(action, service, input) {
|
|
511
|
+
if (action === "account") return service.account();
|
|
512
|
+
if (action === "now") return service.currentPlayer();
|
|
513
|
+
if (action === "devices") return service.devices();
|
|
514
|
+
if (action === "recent") return service.recentActivity(input);
|
|
515
|
+
return service.queue();
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
async function runWriteCommand(action, rest, service) {
|
|
519
|
+
if (["pause", "next", "previous"].includes(action)) {
|
|
520
|
+
exactArguments(action, rest, 0);
|
|
521
|
+
return actionResult(await service[action](), `playback.${action}`);
|
|
522
|
+
}
|
|
523
|
+
if (action === "play") {
|
|
524
|
+
if (rest.length > 1) throw commandError("Usage: moondog spotify play [spotify-uri].");
|
|
525
|
+
const input = rest[0] ? { uris: [rest[0]] } : undefined;
|
|
526
|
+
return actionResult(await service.resume(input), "playback.resume");
|
|
527
|
+
}
|
|
528
|
+
if (action === "volume") {
|
|
529
|
+
exactArguments(action, rest, 1, "volume <0-100>");
|
|
530
|
+
const percent = integerArgument(rest[0], {
|
|
531
|
+
label: "Spotify volume",
|
|
532
|
+
maximum: 100,
|
|
533
|
+
});
|
|
534
|
+
return actionResult(await service.setVolume({ percent }), "playback.volume.set");
|
|
535
|
+
}
|
|
536
|
+
if (action === "seek") {
|
|
537
|
+
exactArguments(action, rest, 1, "seek <milliseconds>");
|
|
538
|
+
const positionMs = integerArgument(rest[0], {
|
|
539
|
+
label: "Spotify seek position",
|
|
540
|
+
maximum: 86_400_000,
|
|
541
|
+
});
|
|
542
|
+
return actionResult(await service.seek({ positionMs }), "playback.seek");
|
|
543
|
+
}
|
|
544
|
+
if (action === "shuffle") {
|
|
545
|
+
exactArguments(action, rest, 1, "shuffle on|off");
|
|
546
|
+
if (!new Set(["on", "off"]).has(rest[0])) {
|
|
547
|
+
throw commandError("Spotify shuffle state must be on or off.");
|
|
548
|
+
}
|
|
549
|
+
return actionResult(
|
|
550
|
+
await service.setShuffle({ state: rest[0] === "on" }),
|
|
551
|
+
"playback.shuffle.set",
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
if (action === "repeat") {
|
|
555
|
+
exactArguments(action, rest, 1, "repeat off|track|context");
|
|
556
|
+
if (!new Set(["off", "track", "context"]).has(rest[0])) {
|
|
557
|
+
throw commandError("Spotify repeat state must be off, track, or context.");
|
|
558
|
+
}
|
|
559
|
+
return actionResult(
|
|
560
|
+
await service.setRepeat({ state: rest[0] }),
|
|
561
|
+
"playback.repeat.set",
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
if (action === "transfer") {
|
|
565
|
+
if (rest.length < 1 || rest.length > 2 || (rest.length === 2 && rest[1] !== "--play")) {
|
|
566
|
+
throw commandError("Usage: moondog spotify transfer <device-id> [--play].");
|
|
567
|
+
}
|
|
568
|
+
return actionResult(
|
|
569
|
+
await service.transfer({ deviceId: rest[0], play: rest[1] === "--play" }),
|
|
570
|
+
"playback.transfer",
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
if (action === "queue-add") {
|
|
574
|
+
if (rest.length < 1 || rest.length > 2) {
|
|
575
|
+
throw commandError(
|
|
576
|
+
"Usage: moondog spotify queue-add <track-or-episode-uri> [device-id].",
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
return actionResult(
|
|
580
|
+
await service.addToQueue({ uri: rest[0], deviceId: rest[1] }),
|
|
581
|
+
"playback.queue.add",
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
throw commandError(`Unknown Spotify command: ${action}`);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
export async function runSpotifyCommand({
|
|
588
|
+
args = [],
|
|
589
|
+
json = false,
|
|
590
|
+
environment = process.env,
|
|
591
|
+
stdout = process.stdout,
|
|
592
|
+
stderr = process.stderr,
|
|
593
|
+
fetchImpl = globalThis.fetch,
|
|
594
|
+
openBrowser,
|
|
595
|
+
credentialStore,
|
|
596
|
+
resolutionCache = null,
|
|
597
|
+
recentActivityStore = null,
|
|
598
|
+
subjectId = null,
|
|
599
|
+
now = Date.now,
|
|
600
|
+
historyArchiveImporter = readSpotifyHistoryArchive,
|
|
601
|
+
spotify,
|
|
602
|
+
signalTarget = process,
|
|
603
|
+
signal,
|
|
604
|
+
} = {}) {
|
|
605
|
+
const options = parseArguments(args, json);
|
|
606
|
+
const action = options.action;
|
|
607
|
+
if (!action || action === "help") {
|
|
608
|
+
exactArguments("help", options.rest, 0, "help");
|
|
609
|
+
writeLine(stdout, spotifyHelpText());
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const knownCommands = new Set([
|
|
614
|
+
"configure",
|
|
615
|
+
"login",
|
|
616
|
+
"status",
|
|
617
|
+
"logout",
|
|
618
|
+
"resolve",
|
|
619
|
+
"sync-recent",
|
|
620
|
+
"import-history",
|
|
621
|
+
...readCommands,
|
|
622
|
+
...writeCommands,
|
|
623
|
+
]);
|
|
624
|
+
if (!knownCommands.has(action)) throw commandError(`Unknown Spotify command: ${action}`);
|
|
625
|
+
|
|
626
|
+
if (action === "import-history") {
|
|
627
|
+
exactArguments(
|
|
628
|
+
action,
|
|
629
|
+
options.rest,
|
|
630
|
+
1,
|
|
631
|
+
"import-history <spotify-history.zip>",
|
|
632
|
+
);
|
|
633
|
+
if (
|
|
634
|
+
!recentActivityStore ||
|
|
635
|
+
typeof recentActivityStore.ingestImport !== "function"
|
|
636
|
+
) {
|
|
637
|
+
throw commandError("Spotify account-data history storage is unavailable.");
|
|
638
|
+
}
|
|
639
|
+
if (typeof subjectId !== "string" || !subjectId.trim()) {
|
|
640
|
+
throw commandError(
|
|
641
|
+
"A trusted local music subject is required for account-data history import.",
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
const currentTime = now();
|
|
645
|
+
if (!Number.isFinite(currentTime)) {
|
|
646
|
+
throw commandError("Spotify account-data import time is unavailable.");
|
|
647
|
+
}
|
|
648
|
+
const bundle = await externalCall(
|
|
649
|
+
() =>
|
|
650
|
+
historyArchiveImporter({
|
|
651
|
+
archivePath: options.rest[0],
|
|
652
|
+
subjectId,
|
|
653
|
+
capturedAt: new Date(currentTime).toISOString(),
|
|
654
|
+
}),
|
|
655
|
+
"Spotify account-data history could not be read.",
|
|
656
|
+
);
|
|
657
|
+
const result = await externalCall(
|
|
658
|
+
() => recentActivityStore.ingestImport(bundle),
|
|
659
|
+
"Spotify account-data history could not be stored.",
|
|
660
|
+
);
|
|
661
|
+
emit(stdout, action, result, options.json);
|
|
662
|
+
return result;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const runtime = createRuntimeResolver({
|
|
666
|
+
environment,
|
|
667
|
+
fetchImpl,
|
|
668
|
+
openBrowser,
|
|
669
|
+
credentialStore,
|
|
670
|
+
spotify,
|
|
671
|
+
historyOnly: action === "login" && options.rest.includes("--history-only"),
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
if (action === "configure") {
|
|
675
|
+
exactArguments(action, options.rest, 1, "configure <client-id>");
|
|
676
|
+
await externalCall(
|
|
677
|
+
() => runtime.configure(options.rest[0]),
|
|
678
|
+
"Spotify configuration could not be saved.",
|
|
679
|
+
);
|
|
680
|
+
const result = {
|
|
681
|
+
provider: "spotify",
|
|
682
|
+
state: "configured",
|
|
683
|
+
client_id_configured: true,
|
|
684
|
+
redirect_uri: SPOTIFY_DEFAULT_REDIRECT_URI,
|
|
685
|
+
};
|
|
686
|
+
emit(stdout, action, result, options.json);
|
|
687
|
+
return result;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
if (action === "login") {
|
|
691
|
+
const historyOnly = options.rest.includes("--history-only");
|
|
692
|
+
const loginArgs = options.rest.filter((arg) => arg !== "--history-only");
|
|
693
|
+
if (loginArgs.length > 1 || options.rest.length - loginArgs.length > 1 || loginArgs.some((arg) => arg.startsWith("--"))) {
|
|
694
|
+
throw commandError("Usage: moondog spotify login [client-id] [--history-only].");
|
|
695
|
+
}
|
|
696
|
+
if (loginArgs[0]) {
|
|
697
|
+
await externalCall(
|
|
698
|
+
() => runtime.configure(loginArgs[0]),
|
|
699
|
+
"Spotify configuration could not be saved.",
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
const authentication = await runtime.authentication();
|
|
703
|
+
const controller = new AbortController();
|
|
704
|
+
const onSigint = () => controller.abort();
|
|
705
|
+
if (signal?.aborted) controller.abort();
|
|
706
|
+
signal?.addEventListener("abort", onSigint, { once: true });
|
|
707
|
+
signalTarget.once("SIGINT", onSigint);
|
|
708
|
+
try {
|
|
709
|
+
const status = await externalCall(
|
|
710
|
+
() =>
|
|
711
|
+
authentication.login({
|
|
712
|
+
signal: controller.signal,
|
|
713
|
+
onAuthorizationUrl(url) {
|
|
714
|
+
writeLine(stderr, "Open this URL to authorize Spotify:");
|
|
715
|
+
writeLine(stderr, url);
|
|
716
|
+
writeLine(stderr, `Redirect URI: ${SPOTIFY_DEFAULT_REDIRECT_URI}`);
|
|
717
|
+
},
|
|
718
|
+
}),
|
|
719
|
+
"Spotify login failed before a usable credential was saved.",
|
|
720
|
+
);
|
|
721
|
+
const result = publicAuthStatus(status, true, historyOnly ? ["user-read-recently-played"] : SPOTIFY_DEFAULT_SCOPES);
|
|
722
|
+
if (historyOnly) result.scope_purpose = "recent_listening";
|
|
723
|
+
emit(stdout, action, result, options.json);
|
|
724
|
+
return result;
|
|
725
|
+
} finally {
|
|
726
|
+
signalTarget.removeListener("SIGINT", onSigint);
|
|
727
|
+
signal?.removeEventListener("abort", onSigint);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
if (action === "status") {
|
|
732
|
+
exactArguments(action, options.rest, 0);
|
|
733
|
+
const configuration = await runtime.configuration();
|
|
734
|
+
const authentication = await runtime.authentication({ required: false });
|
|
735
|
+
const status = authentication
|
|
736
|
+
? await externalCall(
|
|
737
|
+
() => authentication.status(),
|
|
738
|
+
"Spotify authorization status could not be read.",
|
|
739
|
+
)
|
|
740
|
+
: undefined;
|
|
741
|
+
const result = publicAuthStatus(status, Boolean(configuration || spotify?.authentication));
|
|
742
|
+
emit(stdout, action, result, options.json);
|
|
743
|
+
return result;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
if (action === "logout") {
|
|
747
|
+
exactArguments(action, options.rest, 0);
|
|
748
|
+
const configuration = await runtime.configuration();
|
|
749
|
+
const authentication = await runtime.authentication({ required: false });
|
|
750
|
+
let status;
|
|
751
|
+
if (authentication) {
|
|
752
|
+
status = await externalCall(
|
|
753
|
+
() => authentication.logout(),
|
|
754
|
+
"Spotify authorization could not be removed.",
|
|
755
|
+
);
|
|
756
|
+
} else {
|
|
757
|
+
await externalCall(
|
|
758
|
+
() => runtime.deleteCredentialWithoutConfiguration(),
|
|
759
|
+
"Spotify authorization could not be removed.",
|
|
760
|
+
);
|
|
761
|
+
}
|
|
762
|
+
const result = publicAuthStatus(status, Boolean(configuration || spotify?.authentication));
|
|
763
|
+
emit(stdout, action, result, options.json);
|
|
764
|
+
return result;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
if (action === "resolve") {
|
|
768
|
+
const flags = parseResolveFlags(action, options.rest);
|
|
769
|
+
const resolver = await runtime.resolver(resolutionCache);
|
|
770
|
+
const result = await externalCall(
|
|
771
|
+
() =>
|
|
772
|
+
resolver.resolve([
|
|
773
|
+
{
|
|
774
|
+
track_ref_id: "cli",
|
|
775
|
+
title: flags.title,
|
|
776
|
+
artist_credit: flags.artist,
|
|
777
|
+
release: flags.release,
|
|
778
|
+
...(flags.durationMs !== undefined
|
|
779
|
+
? { duration_ms: flags.durationMs }
|
|
780
|
+
: {}),
|
|
781
|
+
},
|
|
782
|
+
]),
|
|
783
|
+
"Spotify catalog resolution failed.",
|
|
784
|
+
);
|
|
785
|
+
emit(stdout, action, result, options.json);
|
|
786
|
+
return result;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
if (readCommands.has(action)) {
|
|
790
|
+
const input =
|
|
791
|
+
action === "recent"
|
|
792
|
+
? parseRecentFlags(action, options.rest)
|
|
793
|
+
: (exactArguments(action, options.rest, 0), undefined);
|
|
794
|
+
const service = await runtime.service();
|
|
795
|
+
const result = await externalCall(
|
|
796
|
+
() => runReadCommand(action, service, input),
|
|
797
|
+
`Spotify ${action} could not be read.`,
|
|
798
|
+
);
|
|
799
|
+
emit(stdout, action, result, options.json);
|
|
800
|
+
return result;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
if (action === "sync-recent") {
|
|
804
|
+
const input = parseRecentFlags(action, options.rest, { cursors: false });
|
|
805
|
+
if (!recentActivityStore || typeof recentActivityStore.ingest !== "function") {
|
|
806
|
+
throw commandError("Spotify recent activity storage is unavailable.");
|
|
807
|
+
}
|
|
808
|
+
if (typeof subjectId !== "string" || !subjectId.trim()) {
|
|
809
|
+
throw commandError("A trusted local music subject is required for recent activity sync.");
|
|
810
|
+
}
|
|
811
|
+
const cursor = recentActivityStore.sourceCursor(
|
|
812
|
+
SPOTIFY_RECENT_ACTIVITY_SOURCE,
|
|
813
|
+
);
|
|
814
|
+
const service = await runtime.service();
|
|
815
|
+
const page = await externalCall(
|
|
816
|
+
() =>
|
|
817
|
+
service.recentActivity({
|
|
818
|
+
...input,
|
|
819
|
+
...(cursor !== null ? { after: cursor } : {}),
|
|
820
|
+
}),
|
|
821
|
+
"Spotify recent activity could not be read.",
|
|
822
|
+
);
|
|
823
|
+
const currentTime = now();
|
|
824
|
+
if (!Number.isFinite(currentTime)) {
|
|
825
|
+
throw commandError("Spotify recent activity capture time is unavailable.");
|
|
826
|
+
}
|
|
827
|
+
const result = await externalCall(
|
|
828
|
+
() =>
|
|
829
|
+
recentActivityStore.ingest(
|
|
830
|
+
projectSpotifyRecentActivity({
|
|
831
|
+
subjectId,
|
|
832
|
+
page,
|
|
833
|
+
capturedAt: new Date(currentTime).toISOString(),
|
|
834
|
+
}),
|
|
835
|
+
),
|
|
836
|
+
"Spotify recent activity could not be stored.",
|
|
837
|
+
);
|
|
838
|
+
emit(stdout, action, result, options.json);
|
|
839
|
+
return result;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
const service = await runtime.service();
|
|
843
|
+
const result = await externalCall(
|
|
844
|
+
() => runWriteCommand(action, options.rest, service),
|
|
845
|
+
`Spotify ${action} failed.`,
|
|
846
|
+
);
|
|
847
|
+
emit(stdout, action, result, options.json);
|
|
848
|
+
return result;
|
|
849
|
+
}
|