@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,954 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
|
|
6
|
+
import { MoondogApplication } from "../src/core/moondog-application.mjs";
|
|
7
|
+
import { openLocalMemoryStore } from "../src/memory/local-memory-store.mjs";
|
|
8
|
+
import {
|
|
9
|
+
openAppleProjectionDomainServices,
|
|
10
|
+
resolveAppleMusicSubjectId,
|
|
11
|
+
} from "../src/core/apple-projection-domain-services.mjs";
|
|
12
|
+
import { readAppleMusicSourceStatus } from "../src/core/apple-library-source-status.mjs";
|
|
13
|
+
import { createListeningProfileDomainServices } from "../src/core/listening-profile-domain-services.mjs";
|
|
14
|
+
import { runMemoryReflection } from "../src/memory/reflection-worker.mjs";
|
|
15
|
+
import { createConfiguredRuntime } from "../src/runtime/pi/configured-runtime.mjs";
|
|
16
|
+
import { writePiRuntimeSelection } from "../src/runtime/pi/runtime-settings.mjs";
|
|
17
|
+
import {
|
|
18
|
+
formatLocalResult,
|
|
19
|
+
formatDemoResult,
|
|
20
|
+
formatMemoryReflection,
|
|
21
|
+
helpText,
|
|
22
|
+
sanitizeTerminalText,
|
|
23
|
+
} from "../src/surfaces/cli/format-output.mjs";
|
|
24
|
+
import { runAuthCommand } from "../src/surfaces/cli/auth-command.mjs";
|
|
25
|
+
import { runDataCommand } from "../src/surfaces/cli/data-command.mjs";
|
|
26
|
+
import { migrateLegacyAppleMusicData } from "../src/core/apple-data-migration.mjs";
|
|
27
|
+
import { runCatalogCommand } from "../src/surfaces/cli/catalog-command.mjs";
|
|
28
|
+
import { runProfileCommand } from "../src/surfaces/cli/profile-command.mjs";
|
|
29
|
+
import { createCodexWebResearch } from "../src/integrations/web/codex-web.mjs";
|
|
30
|
+
import { runWebCommand } from "../src/surfaces/cli/web-command.mjs";
|
|
31
|
+
import { openSpotifyConnection } from "../src/integrations/spotify/connection.mjs";
|
|
32
|
+
import {
|
|
33
|
+
openSpotifyResolutionCache,
|
|
34
|
+
} from "../src/integrations/spotify/resolution-cache.mjs";
|
|
35
|
+
import {
|
|
36
|
+
readSpotifyConfiguration,
|
|
37
|
+
} from "../src/integrations/spotify/settings.mjs";
|
|
38
|
+
import { runSpotifyCommand } from "../src/surfaces/cli/spotify-command.mjs";
|
|
39
|
+
import { runListenBrainzCommand } from "../src/surfaces/cli/listenbrainz-command.mjs";
|
|
40
|
+
import { openListeningHistoryStore } from "../src/profile/listening-history-store.mjs";
|
|
41
|
+
import { prepareHistoryImport, prepareSpotifyRecentImport } from "../src/profile/history-import.mjs";
|
|
42
|
+
import { persistAppleLibraryImport, refreshAppleLibraryImport } from "../src/profile/apple-library-import.mjs";
|
|
43
|
+
import { IMPORT_GUIDE_URLS } from "../src/surfaces/cli/import-guides.mjs";
|
|
44
|
+
import { runMoondogTui } from "../src/surfaces/cli/tui.mjs";
|
|
45
|
+
import { createLyricService } from "../src/core/lyric-service.mjs";
|
|
46
|
+
import { createAppleMusicCatalog } from "../src/integrations/apple-music/catalog.mjs";
|
|
47
|
+
import {
|
|
48
|
+
createOpenMusicSimilarity,
|
|
49
|
+
createWikidataArtistResolver,
|
|
50
|
+
} from "../src/integrations/open-music-similarity/artist-radio.mjs";
|
|
51
|
+
import { runMoondogDemo } from "../src/demo/moondog-demo.mjs";
|
|
52
|
+
import { writeFictionalSpotifyHistoryArchive } from "../src/demo/fictional-spotify-history.mjs";
|
|
53
|
+
import { writeTasteprintArtifact } from "../src/surfaces/html/tasteprint-artifact.mjs";
|
|
54
|
+
import {
|
|
55
|
+
persistTasteFromSpotifyArchive,
|
|
56
|
+
projectTasteFromSpotifyArchives,
|
|
57
|
+
projectTasteFromSpotifyArchive,
|
|
58
|
+
SPOTIFY_ARCHIVE_SET_MAXIMUM,
|
|
59
|
+
} from "../src/profile/spotify-archive-taste.mjs";
|
|
60
|
+
import { deriveArtistReleaseHintsFromSpotifyArchive } from "../src/profile/spotify-archive-catalog-hints.mjs";
|
|
61
|
+
import { runMoondogStudio } from "../src/surfaces/web/studio.mjs";
|
|
62
|
+
|
|
63
|
+
const localCommands = new Set([
|
|
64
|
+
"status",
|
|
65
|
+
"sources",
|
|
66
|
+
"profile",
|
|
67
|
+
"taste",
|
|
68
|
+
"memory",
|
|
69
|
+
"tools",
|
|
70
|
+
"doctor",
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
function parseArguments(argv) {
|
|
74
|
+
const values = [...argv];
|
|
75
|
+
const jsonIndex = values.indexOf("--json");
|
|
76
|
+
const json = jsonIndex !== -1;
|
|
77
|
+
if (json) values.splice(jsonIndex, 1);
|
|
78
|
+
const dryRunIndex = values.indexOf("--dry-run");
|
|
79
|
+
const dryRun = dryRunIndex !== -1;
|
|
80
|
+
if (dryRun) values.splice(dryRunIndex, 1);
|
|
81
|
+
const offlineIndex = values.indexOf("--offline");
|
|
82
|
+
const offline = offlineIndex !== -1;
|
|
83
|
+
if (offline) values.splice(offlineIndex, 1);
|
|
84
|
+
const htmlIndex = values.indexOf("--html");
|
|
85
|
+
const html = htmlIndex !== -1;
|
|
86
|
+
if (html) values.splice(htmlIndex, 1);
|
|
87
|
+
const cardIndex = values.indexOf("--card");
|
|
88
|
+
const card = cardIndex !== -1;
|
|
89
|
+
if (card) values.splice(cardIndex, 1);
|
|
90
|
+
const saveIndex = values.indexOf("--save");
|
|
91
|
+
const save = saveIndex !== -1;
|
|
92
|
+
if (save) values.splice(saveIndex, 1);
|
|
93
|
+
const outputIndex = values.indexOf("--output");
|
|
94
|
+
let output = null;
|
|
95
|
+
if (outputIndex !== -1) {
|
|
96
|
+
if (values.lastIndexOf("--output") !== outputIndex) {
|
|
97
|
+
throw new Error("The --output option may be provided only once.");
|
|
98
|
+
}
|
|
99
|
+
output = values[outputIndex + 1];
|
|
100
|
+
if (!output || output.startsWith("--")) {
|
|
101
|
+
throw new Error("The --output option requires a file or directory path.");
|
|
102
|
+
}
|
|
103
|
+
values.splice(outputIndex, 2);
|
|
104
|
+
}
|
|
105
|
+
const fromArchives = [];
|
|
106
|
+
let fromIndex = values.indexOf("--from");
|
|
107
|
+
while (fromIndex !== -1) {
|
|
108
|
+
const archivePath = values[fromIndex + 1];
|
|
109
|
+
if (!archivePath || archivePath.startsWith("--")) {
|
|
110
|
+
throw new Error("The --from option requires a Spotify history ZIP path.");
|
|
111
|
+
}
|
|
112
|
+
fromArchives.push(archivePath);
|
|
113
|
+
values.splice(fromIndex, 2);
|
|
114
|
+
fromIndex = values.indexOf("--from");
|
|
115
|
+
}
|
|
116
|
+
if (fromArchives.length > SPOTIFY_ARCHIVE_SET_MAXIMUM) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`The --from option may be provided at most ${SPOTIFY_ARCHIVE_SET_MAXIMUM} times.`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
command: values[0],
|
|
123
|
+
rest: values.slice(1),
|
|
124
|
+
json,
|
|
125
|
+
dryRun,
|
|
126
|
+
offline,
|
|
127
|
+
html,
|
|
128
|
+
card,
|
|
129
|
+
save,
|
|
130
|
+
output,
|
|
131
|
+
from: fromArchives[0] ?? null,
|
|
132
|
+
fromArchives,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function optionalAppleMusicSubjectId() {
|
|
137
|
+
const source = await readAppleMusicSourceStatus();
|
|
138
|
+
if (source.state === "missing") return null;
|
|
139
|
+
if (source.state === "invalid") {
|
|
140
|
+
const error = new Error("The canonical Apple Music import source is invalid");
|
|
141
|
+
error.code = "projection_source_invalid";
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
return resolveAppleMusicSubjectId();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function projectionErrorCode(error) {
|
|
148
|
+
return typeof error?.code === "string" &&
|
|
149
|
+
/^projection_[a-z0-9_]+$/u.test(error.code)
|
|
150
|
+
? error.code
|
|
151
|
+
: null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function loadDomainServices() {
|
|
155
|
+
await migrateLegacyAppleMusicData();
|
|
156
|
+
const listeningHistoryStore = await openListeningHistoryStore();
|
|
157
|
+
let domainServicesError = null;
|
|
158
|
+
let subjectId = null;
|
|
159
|
+
try {
|
|
160
|
+
const appleSubjectId = await optionalAppleMusicSubjectId();
|
|
161
|
+
subjectId = listeningHistoryStore.localSubjectId({
|
|
162
|
+
...(appleSubjectId ? { preferredSubjectId: appleSubjectId } : {}),
|
|
163
|
+
});
|
|
164
|
+
if (appleSubjectId) {
|
|
165
|
+
try {
|
|
166
|
+
return {
|
|
167
|
+
domainServices: await openAppleProjectionDomainServices({
|
|
168
|
+
listeningHistoryStore,
|
|
169
|
+
subjectId: appleSubjectId,
|
|
170
|
+
}),
|
|
171
|
+
domainServicesError: null,
|
|
172
|
+
};
|
|
173
|
+
} catch (error) {
|
|
174
|
+
domainServicesError = projectionErrorCode(error);
|
|
175
|
+
if (!domainServicesError) throw error;
|
|
176
|
+
}
|
|
177
|
+
} else {
|
|
178
|
+
domainServicesError = "projection_subject_unavailable";
|
|
179
|
+
}
|
|
180
|
+
} catch (error) {
|
|
181
|
+
domainServicesError = projectionErrorCode(error);
|
|
182
|
+
if (!domainServicesError) {
|
|
183
|
+
listeningHistoryStore.close();
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
subjectId ??= listeningHistoryStore.localSubjectId();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (
|
|
190
|
+
subjectId &&
|
|
191
|
+
listeningHistoryStore.subjectDataStatus({ subjectId }).state === "ready"
|
|
192
|
+
) {
|
|
193
|
+
return {
|
|
194
|
+
domainServices: createListeningProfileDomainServices({
|
|
195
|
+
listeningHistoryStore,
|
|
196
|
+
subjectId,
|
|
197
|
+
}),
|
|
198
|
+
domainServicesError,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
listeningHistoryStore.close();
|
|
202
|
+
return { domainServices: null, domainServicesError };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
let spotifyResolutionCachePromise = null;
|
|
206
|
+
|
|
207
|
+
function spotifyResolutionCache() {
|
|
208
|
+
spotifyResolutionCachePromise ??= openSpotifyResolutionCache().catch(
|
|
209
|
+
() => null,
|
|
210
|
+
);
|
|
211
|
+
return spotifyResolutionCachePromise;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function closeSpotifyResolutionCache() {
|
|
215
|
+
if (!spotifyResolutionCachePromise) return;
|
|
216
|
+
const cache = await spotifyResolutionCachePromise;
|
|
217
|
+
cache?.close?.();
|
|
218
|
+
spotifyResolutionCachePromise = null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function spotifyResolutionCacheIfConfigured() {
|
|
222
|
+
return (await readSpotifyConfiguration(process.env))
|
|
223
|
+
? await spotifyResolutionCache()
|
|
224
|
+
: null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function loadSpotifyConnection() {
|
|
228
|
+
try {
|
|
229
|
+
return await openSpotifyConnection({
|
|
230
|
+
resolutionCache: await spotifyResolutionCacheIfConfigured(),
|
|
231
|
+
});
|
|
232
|
+
} catch (error) {
|
|
233
|
+
const reason =
|
|
234
|
+
typeof error?.code === "string" && /^spotify_[a-z0-9_]+$/u.test(error.code)
|
|
235
|
+
? error.code
|
|
236
|
+
: "spotify_connection_failed";
|
|
237
|
+
return {
|
|
238
|
+
service: null,
|
|
239
|
+
authentication: null,
|
|
240
|
+
ready: () => false,
|
|
241
|
+
publicStatus: () => ({
|
|
242
|
+
provider: "spotify",
|
|
243
|
+
state: "unavailable",
|
|
244
|
+
reason,
|
|
245
|
+
client_id_configured: false,
|
|
246
|
+
external_effects: "disabled",
|
|
247
|
+
}),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function spotifyRecentSyncContext(args) {
|
|
253
|
+
if (!new Set(["sync-recent", "import-history"]).has(args[0])) return {};
|
|
254
|
+
const recentActivityStore = await openListeningHistoryStore();
|
|
255
|
+
try {
|
|
256
|
+
const appleSubjectId = await optionalAppleMusicSubjectId();
|
|
257
|
+
return {
|
|
258
|
+
subjectId: recentActivityStore.localSubjectId({
|
|
259
|
+
...(appleSubjectId ? { preferredSubjectId: appleSubjectId } : {}),
|
|
260
|
+
create: true,
|
|
261
|
+
}),
|
|
262
|
+
recentActivityStore,
|
|
263
|
+
};
|
|
264
|
+
} catch (error) {
|
|
265
|
+
recentActivityStore.close();
|
|
266
|
+
throw error;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function runSpotifySurface(options) {
|
|
271
|
+
const syncContext = await spotifyRecentSyncContext(options.args);
|
|
272
|
+
try {
|
|
273
|
+
return await runSpotifyCommand({
|
|
274
|
+
...options,
|
|
275
|
+
...syncContext,
|
|
276
|
+
});
|
|
277
|
+
} finally {
|
|
278
|
+
syncContext.recentActivityStore?.close?.();
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function runListenBrainzSurface(options) {
|
|
283
|
+
if (options.args[0] !== "import-history") {
|
|
284
|
+
return runListenBrainzCommand(options);
|
|
285
|
+
}
|
|
286
|
+
const historyStore = await openListeningHistoryStore();
|
|
287
|
+
try {
|
|
288
|
+
const appleSubjectId = await optionalAppleMusicSubjectId();
|
|
289
|
+
const subjectId = historyStore.localSubjectId({
|
|
290
|
+
...(appleSubjectId ? { preferredSubjectId: appleSubjectId } : {}),
|
|
291
|
+
create: true,
|
|
292
|
+
});
|
|
293
|
+
return await runListenBrainzCommand({
|
|
294
|
+
...options,
|
|
295
|
+
historyStore,
|
|
296
|
+
subjectId,
|
|
297
|
+
});
|
|
298
|
+
} finally {
|
|
299
|
+
historyStore.close();
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function prepareTuiHistoryImport(filePath, { recentPage, provider } = {}) {
|
|
304
|
+
const historyStore = await openListeningHistoryStore();
|
|
305
|
+
try {
|
|
306
|
+
const appleSubjectId = await optionalAppleMusicSubjectId();
|
|
307
|
+
const subjectId = historyStore.localSubjectId({
|
|
308
|
+
...(appleSubjectId ? { preferredSubjectId: appleSubjectId } : {}),
|
|
309
|
+
create: true,
|
|
310
|
+
});
|
|
311
|
+
const prepared = recentPage ? prepareSpotifyRecentImport({ page: recentPage, subjectId }) : await prepareHistoryImport({
|
|
312
|
+
filePath, subjectId, provider, capturedAt: new Date().toISOString(),
|
|
313
|
+
});
|
|
314
|
+
let closed = false;
|
|
315
|
+
let committed = false;
|
|
316
|
+
return {
|
|
317
|
+
provider: prepared.provider,
|
|
318
|
+
preview: prepared.preview,
|
|
319
|
+
async commit() {
|
|
320
|
+
if (closed || committed) throw new Error("That preview is gone. Open it again before importing.");
|
|
321
|
+
const options = { args: ["import-history", filePath], stdout: { write() {} }, subjectId };
|
|
322
|
+
// Commit the exact inspected bundle, even if the source file later changes.
|
|
323
|
+
const receipt = prepared.provider === "spotify-recent"
|
|
324
|
+
? historyStore.ingest(prepared.bundle)
|
|
325
|
+
: prepared.provider === "apple-music-library"
|
|
326
|
+
? await persistAppleLibraryImport(prepared.bundle)
|
|
327
|
+
: prepared.provider === "spotify"
|
|
328
|
+
? await runSpotifyCommand({
|
|
329
|
+
...options, recentActivityStore: historyStore,
|
|
330
|
+
historyArchiveImporter: async () => prepared.bundle,
|
|
331
|
+
})
|
|
332
|
+
: prepared.provider === "listenbrainz" ? await runListenBrainzCommand({
|
|
333
|
+
...options, historyStore,
|
|
334
|
+
historyFileImporter: async () => prepared.bundle,
|
|
335
|
+
}) : { ...historyStore.ingestImport(prepared.bundle), provider: prepared.provider };
|
|
336
|
+
committed = true;
|
|
337
|
+
return receipt;
|
|
338
|
+
},
|
|
339
|
+
close() {
|
|
340
|
+
if (closed) return;
|
|
341
|
+
closed = true;
|
|
342
|
+
historyStore.close();
|
|
343
|
+
},
|
|
344
|
+
};
|
|
345
|
+
} catch (error) {
|
|
346
|
+
historyStore.close();
|
|
347
|
+
throw error;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async function openImportHelp(destination) {
|
|
352
|
+
const url = IMPORT_GUIDE_URLS[destination];
|
|
353
|
+
if (!url) throw new Error("Unknown import guide.");
|
|
354
|
+
await promisify(execFile)(process.platform === "darwin" ? "open" : "xdg-open", [url], {
|
|
355
|
+
timeout: 10_000,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function tasteCoverage(profile) {
|
|
360
|
+
return {
|
|
361
|
+
effective_listening_events:
|
|
362
|
+
profile.coverage?.effective_listening_events ?? null,
|
|
363
|
+
listening_hours: profile.coverage?.listening_hours ?? null,
|
|
364
|
+
distinct_tracks: profile.coverage?.listening_tracks ?? null,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function tasteSourceLabel(source) {
|
|
369
|
+
if (source?.data_scope === "combined_spotify_history") {
|
|
370
|
+
return "combined Spotify history archives";
|
|
371
|
+
}
|
|
372
|
+
if (source?.data_scope === "lifetime_extended_streaming_history") {
|
|
373
|
+
return "Spotify Extended Streaming History";
|
|
374
|
+
}
|
|
375
|
+
if (source?.data_scope === "past_year_account_data") {
|
|
376
|
+
return "Spotify Account Data";
|
|
377
|
+
}
|
|
378
|
+
return "Spotify history";
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function emitTasteprintArtifact(
|
|
382
|
+
profile,
|
|
383
|
+
{ output, json, generatedAt, source = null, format = "full" } = {},
|
|
384
|
+
) {
|
|
385
|
+
const artifact = await writeTasteprintArtifact(profile, {
|
|
386
|
+
outputPath: output ?? undefined,
|
|
387
|
+
generatedAt,
|
|
388
|
+
format,
|
|
389
|
+
});
|
|
390
|
+
const result = {
|
|
391
|
+
...artifact,
|
|
392
|
+
coverage: tasteCoverage(profile),
|
|
393
|
+
...(source ? { source } : {}),
|
|
394
|
+
};
|
|
395
|
+
if (json) {
|
|
396
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
397
|
+
} else {
|
|
398
|
+
const lines = [
|
|
399
|
+
format === "card"
|
|
400
|
+
? "Your recap card is ready."
|
|
401
|
+
: "Your listening report is ready.",
|
|
402
|
+
`Saved to ${artifact.path}`,
|
|
403
|
+
`Read from ${(result.coverage.effective_listening_events ?? 0).toLocaleString("en-US")} plays, ${(result.coverage.listening_hours ?? 0).toLocaleString("en-US")} hours, ${(result.coverage.distinct_tracks ?? 0).toLocaleString("en-US")} tracks.`,
|
|
404
|
+
];
|
|
405
|
+
if (source?.persistent_import === false) {
|
|
406
|
+
lines.push(
|
|
407
|
+
`From the ${tasteSourceLabel(source)} you chose. Nothing was saved to your profile.`,
|
|
408
|
+
);
|
|
409
|
+
} else if (source?.persistent_import === true) {
|
|
410
|
+
lines.push(
|
|
411
|
+
`The ${tasteSourceLabel(source)} is now saved in your profile.`,
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
lines.push(
|
|
415
|
+
"Open it in any browser. It works offline and loads nothing from the internet.",
|
|
416
|
+
);
|
|
417
|
+
if (format === "card") {
|
|
418
|
+
lines.push(
|
|
419
|
+
"Read it over before you share it. Everything on it is about you.",
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
process.stdout.write(`${lines.join("\n")}\n`);
|
|
423
|
+
}
|
|
424
|
+
return result;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
async function main() {
|
|
428
|
+
const options = parseArguments(process.argv.slice(2));
|
|
429
|
+
|
|
430
|
+
if (options.command === "help" || options.command === "--help" || options.command === "-h") {
|
|
431
|
+
process.stdout.write(`${helpText()}\n`);
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (options.command === "web") {
|
|
436
|
+
if (options.dryRun || options.offline || options.html || options.card || options.save || options.from || options.output) {
|
|
437
|
+
throw new Error("Usage: moondog web status|search|read [arguments] [--json].");
|
|
438
|
+
}
|
|
439
|
+
const webResearch = await createCodexWebResearch();
|
|
440
|
+
const controller = new AbortController();
|
|
441
|
+
const abort = () => controller.abort();
|
|
442
|
+
process.once("SIGINT", abort);
|
|
443
|
+
process.once("SIGTERM", abort);
|
|
444
|
+
try {
|
|
445
|
+
process.stdout.write(`${await runWebCommand({ args: options.rest, webResearch, json: options.json, signal: controller.signal })}\n`);
|
|
446
|
+
} finally {
|
|
447
|
+
process.removeListener("SIGINT", abort);
|
|
448
|
+
process.removeListener("SIGTERM", abort);
|
|
449
|
+
webResearch.close();
|
|
450
|
+
}
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (options.command === "studio") {
|
|
455
|
+
const demoOnly =
|
|
456
|
+
options.rest.length === 1 && options.rest[0] === "--demo";
|
|
457
|
+
if (
|
|
458
|
+
(options.rest.length > 0 && !demoOnly) ||
|
|
459
|
+
(demoOnly && options.from) ||
|
|
460
|
+
options.json ||
|
|
461
|
+
options.dryRun ||
|
|
462
|
+
options.offline ||
|
|
463
|
+
options.html ||
|
|
464
|
+
options.card ||
|
|
465
|
+
options.save ||
|
|
466
|
+
options.output
|
|
467
|
+
) {
|
|
468
|
+
throw new Error(
|
|
469
|
+
"Usage: moondog studio [--demo | --from <spotify-history.zip> [--from <spotify-history.zip>]].",
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
await runMoondogStudio({
|
|
473
|
+
demoOnly,
|
|
474
|
+
archivePaths: options.fromArchives,
|
|
475
|
+
});
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if (options.command === "demo-history") {
|
|
480
|
+
if (
|
|
481
|
+
options.rest.length > 0 ||
|
|
482
|
+
options.dryRun ||
|
|
483
|
+
options.offline ||
|
|
484
|
+
options.html ||
|
|
485
|
+
options.card ||
|
|
486
|
+
options.save ||
|
|
487
|
+
options.from ||
|
|
488
|
+
!options.output
|
|
489
|
+
) {
|
|
490
|
+
throw new Error(
|
|
491
|
+
"Usage: moondog demo-history --output <absolute-file.zip> [--json].",
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
const result = await writeFictionalSpotifyHistoryArchive({
|
|
495
|
+
outputPath: options.output,
|
|
496
|
+
});
|
|
497
|
+
if (options.json) {
|
|
498
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
499
|
+
} else {
|
|
500
|
+
process.stdout.write(
|
|
501
|
+
[
|
|
502
|
+
"Made a fictional Spotify history ZIP.",
|
|
503
|
+
`Archive: ${sanitizeTerminalText(result.archive_path)}`,
|
|
504
|
+
`${result.record_count} plays across ${result.years.join(", ")}.`,
|
|
505
|
+
`SHA-256: ${result.archive_sha256}`,
|
|
506
|
+
"None of your own data was read, and nothing was saved or sent anywhere.",
|
|
507
|
+
"Next: run moondog, type /import, and choose this ZIP under Spotify > Add past listening history.",
|
|
508
|
+
].join("\n") + "\n",
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (options.command === "catalog") {
|
|
515
|
+
if (
|
|
516
|
+
options.dryRun ||
|
|
517
|
+
options.offline ||
|
|
518
|
+
options.html ||
|
|
519
|
+
options.card ||
|
|
520
|
+
options.save ||
|
|
521
|
+
options.output ||
|
|
522
|
+
options.fromArchives.length > 1
|
|
523
|
+
) {
|
|
524
|
+
throw new Error(
|
|
525
|
+
"Usage: moondog catalog latest-single --artist <name> [--artist-page <Apple Music artist URL> | --known-release <title> | --from <spotify-history.zip>] [--json].",
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
const catalog = createAppleMusicCatalog();
|
|
529
|
+
const identityResolver = createWikidataArtistResolver();
|
|
530
|
+
try {
|
|
531
|
+
await runCatalogCommand({
|
|
532
|
+
args: options.rest,
|
|
533
|
+
json: options.json,
|
|
534
|
+
archivePath: options.from ?? undefined,
|
|
535
|
+
deriveArchiveHints: deriveArtistReleaseHintsFromSpotifyArchive,
|
|
536
|
+
catalog,
|
|
537
|
+
identityResolver,
|
|
538
|
+
});
|
|
539
|
+
} finally {
|
|
540
|
+
catalog.close?.();
|
|
541
|
+
}
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
if (options.command === "data") {
|
|
546
|
+
await migrateLegacyAppleMusicData();
|
|
547
|
+
if (
|
|
548
|
+
options.dryRun ||
|
|
549
|
+
options.offline ||
|
|
550
|
+
options.html ||
|
|
551
|
+
options.card ||
|
|
552
|
+
options.save ||
|
|
553
|
+
options.from
|
|
554
|
+
) {
|
|
555
|
+
throw new Error(
|
|
556
|
+
"Usage: moondog data <inspect|export|reset> --scope <listening|apple|profile>.",
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
await runDataCommand({
|
|
560
|
+
args: options.rest,
|
|
561
|
+
json: options.json,
|
|
562
|
+
outputPath: options.output,
|
|
563
|
+
});
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
if (options.command === "profile" && options.rest.length > 0) {
|
|
568
|
+
if (
|
|
569
|
+
options.dryRun ||
|
|
570
|
+
options.offline ||
|
|
571
|
+
options.html ||
|
|
572
|
+
options.card ||
|
|
573
|
+
options.save ||
|
|
574
|
+
options.output ||
|
|
575
|
+
options.from
|
|
576
|
+
) {
|
|
577
|
+
throw new Error(
|
|
578
|
+
"Usage: moondog profile <corrections|correct|retract>.",
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
await runProfileCommand({
|
|
582
|
+
args: options.rest,
|
|
583
|
+
json: options.json,
|
|
584
|
+
resolvePreferredSubjectId: optionalAppleMusicSubjectId,
|
|
585
|
+
});
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
if (options.offline && options.command !== "demo") {
|
|
590
|
+
throw new Error("The --offline option is only valid for moondog demo.");
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
if (options.html && options.command !== "taste") {
|
|
594
|
+
throw new Error("The --html option is only valid for moondog taste.");
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
if (options.card && options.command !== "taste") {
|
|
598
|
+
throw new Error("The --card option is only valid for moondog taste.");
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
if (options.html && options.card) {
|
|
602
|
+
throw new Error("Choose either --html or --card for moondog taste, not both.");
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
if (options.save && options.command !== "taste") {
|
|
606
|
+
throw new Error("The --save option is only valid for moondog taste.");
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
if (options.from && options.command !== "taste") {
|
|
610
|
+
throw new Error("The --from option is only valid for moondog taste.");
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
if (options.save && !options.from) {
|
|
614
|
+
throw new Error("The --save option requires --from <spotify-history.zip>.");
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
if (options.save && options.fromArchives.length > 1) {
|
|
618
|
+
throw new Error(
|
|
619
|
+
"The two-archive --from path is an in-memory preview. Persist each ZIP with moondog spotify import-history, then run moondog taste --html.",
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
if (
|
|
624
|
+
options.output &&
|
|
625
|
+
!(options.command === "taste" && (options.html || options.card))
|
|
626
|
+
) {
|
|
627
|
+
throw new Error("The --output option requires moondog taste --html or --card.");
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
if (options.command === "taste" && options.rest.length > 0) {
|
|
631
|
+
throw new Error(
|
|
632
|
+
"Usage: moondog taste [--from <spotify-history.zip> [--from <spotify-history.zip>] [--save]] [--json] [(--html|--card) [--output <file.html>]].",
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
if (options.command === "auth") {
|
|
637
|
+
await runAuthCommand({
|
|
638
|
+
args: options.rest,
|
|
639
|
+
json: options.json,
|
|
640
|
+
});
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
if (options.command === "spotify") {
|
|
645
|
+
await runSpotifySurface({
|
|
646
|
+
args: options.rest,
|
|
647
|
+
json: options.json,
|
|
648
|
+
resolutionCache:
|
|
649
|
+
options.rest[0] === "resolve"
|
|
650
|
+
? await spotifyResolutionCacheIfConfigured()
|
|
651
|
+
: null,
|
|
652
|
+
});
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
if (options.command === "listenbrainz") {
|
|
657
|
+
if (
|
|
658
|
+
options.dryRun ||
|
|
659
|
+
options.offline ||
|
|
660
|
+
options.html ||
|
|
661
|
+
options.save ||
|
|
662
|
+
options.output ||
|
|
663
|
+
options.from
|
|
664
|
+
) {
|
|
665
|
+
throw new Error(
|
|
666
|
+
"Usage: moondog listenbrainz import-history <listen-history.json> [--json].",
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
await runListenBrainzSurface({
|
|
670
|
+
args: options.rest,
|
|
671
|
+
json: options.json,
|
|
672
|
+
});
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
if (options.command === "demo") {
|
|
677
|
+
if (options.dryRun) {
|
|
678
|
+
throw new Error("The --dry-run option is not valid for moondog demo.");
|
|
679
|
+
}
|
|
680
|
+
if (options.offline && options.rest.length > 0) {
|
|
681
|
+
throw new Error("Usage: moondog demo --offline [--json].");
|
|
682
|
+
}
|
|
683
|
+
const prompt = options.rest.join(" ").trim() || undefined;
|
|
684
|
+
const value = await runMoondogDemo({ prompt, offline: options.offline });
|
|
685
|
+
process.stdout.write(
|
|
686
|
+
`${options.json ? JSON.stringify(value, null, 2) : formatDemoResult(value)}\n`,
|
|
687
|
+
);
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
if (options.command === "memory" && options.rest[0] === "reflect") {
|
|
692
|
+
if (options.rest.length !== 1) {
|
|
693
|
+
throw new Error("Usage: moondog memory reflect [--dry-run] [--json].");
|
|
694
|
+
}
|
|
695
|
+
const memoryStore = await openLocalMemoryStore();
|
|
696
|
+
const application = new MoondogApplication({ memoryStore });
|
|
697
|
+
try {
|
|
698
|
+
const value = await runMemoryReflection({
|
|
699
|
+
application,
|
|
700
|
+
dryRun: options.dryRun,
|
|
701
|
+
});
|
|
702
|
+
process.stdout.write(
|
|
703
|
+
`${options.json ? JSON.stringify(value, null, 2) : formatMemoryReflection(value)}\n`,
|
|
704
|
+
);
|
|
705
|
+
} finally {
|
|
706
|
+
application.close();
|
|
707
|
+
}
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
if (options.dryRun) {
|
|
712
|
+
throw new Error("The --dry-run option is only valid for memory reflect.");
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
if (options.command === "taste" && options.from) {
|
|
716
|
+
const generatedAt = new Date().toISOString();
|
|
717
|
+
let listeningHistoryStore;
|
|
718
|
+
let preview;
|
|
719
|
+
try {
|
|
720
|
+
if (options.save) {
|
|
721
|
+
listeningHistoryStore = await openListeningHistoryStore();
|
|
722
|
+
const appleSubjectId = await optionalAppleMusicSubjectId();
|
|
723
|
+
const subjectId = listeningHistoryStore.localSubjectId({
|
|
724
|
+
...(appleSubjectId ? { preferredSubjectId: appleSubjectId } : {}),
|
|
725
|
+
create: true,
|
|
726
|
+
});
|
|
727
|
+
preview = await persistTasteFromSpotifyArchive({
|
|
728
|
+
archivePath: options.from,
|
|
729
|
+
subjectId,
|
|
730
|
+
store: listeningHistoryStore,
|
|
731
|
+
capturedAt: generatedAt,
|
|
732
|
+
maxItems: 10,
|
|
733
|
+
});
|
|
734
|
+
} else {
|
|
735
|
+
preview = options.fromArchives.length === 1
|
|
736
|
+
? await projectTasteFromSpotifyArchive({
|
|
737
|
+
archivePath: options.from,
|
|
738
|
+
capturedAt: generatedAt,
|
|
739
|
+
maxItems: 10,
|
|
740
|
+
})
|
|
741
|
+
: await projectTasteFromSpotifyArchives({
|
|
742
|
+
archivePaths: options.fromArchives,
|
|
743
|
+
capturedAt: generatedAt,
|
|
744
|
+
maxItems: 10,
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
} finally {
|
|
748
|
+
listeningHistoryStore?.close?.();
|
|
749
|
+
}
|
|
750
|
+
const profile = {
|
|
751
|
+
...preview.profile,
|
|
752
|
+
preview_source: preview.source,
|
|
753
|
+
};
|
|
754
|
+
if (options.html || options.card) {
|
|
755
|
+
await emitTasteprintArtifact(profile, {
|
|
756
|
+
output: options.output,
|
|
757
|
+
json: options.json,
|
|
758
|
+
generatedAt,
|
|
759
|
+
source: preview.source,
|
|
760
|
+
format: options.card ? "card" : "full",
|
|
761
|
+
});
|
|
762
|
+
} else {
|
|
763
|
+
process.stdout.write(
|
|
764
|
+
`${options.json ? JSON.stringify(profile, null, 2) : formatLocalResult("taste", profile)}\n`,
|
|
765
|
+
);
|
|
766
|
+
}
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
const domainState = await loadDomainServices();
|
|
771
|
+
const memoryStore = await openLocalMemoryStore();
|
|
772
|
+
const spotifyConnection = await loadSpotifyConnection();
|
|
773
|
+
const musicCatalog = createAppleMusicCatalog();
|
|
774
|
+
const artistIdentityResolver = createWikidataArtistResolver();
|
|
775
|
+
const musicSimilarity = createOpenMusicSimilarity({
|
|
776
|
+
identityResolver: artistIdentityResolver,
|
|
777
|
+
});
|
|
778
|
+
const webResearch = await createCodexWebResearch();
|
|
779
|
+
const application = new MoondogApplication({
|
|
780
|
+
...domainState,
|
|
781
|
+
webResearch,
|
|
782
|
+
memoryStore,
|
|
783
|
+
spotifyConnection,
|
|
784
|
+
musicCatalog,
|
|
785
|
+
musicSimilarity,
|
|
786
|
+
artistIdentityResolver,
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
let lyrics = null;
|
|
790
|
+
try {
|
|
791
|
+
const runtime = await createConfiguredRuntime(application);
|
|
792
|
+
const runtimeStatus = runtime.publicStatus();
|
|
793
|
+
|
|
794
|
+
if (localCommands.has(options.command)) {
|
|
795
|
+
if (options.command === "taste" && (options.html || options.card)) {
|
|
796
|
+
const generatedAt = new Date().toISOString();
|
|
797
|
+
const profile = await application.getProfileSummary({ maxItems: 10 });
|
|
798
|
+
await emitTasteprintArtifact(profile, {
|
|
799
|
+
output: options.output,
|
|
800
|
+
json: options.json,
|
|
801
|
+
generatedAt,
|
|
802
|
+
format: options.card ? "card" : "full",
|
|
803
|
+
});
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
const value = await application.runLocalCommand(
|
|
807
|
+
options.command,
|
|
808
|
+
runtimeStatus,
|
|
809
|
+
);
|
|
810
|
+
process.stdout.write(
|
|
811
|
+
`${options.json ? JSON.stringify(value, null, 2) : formatLocalResult(options.command, value)}\n`,
|
|
812
|
+
);
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
if (options.command === "remember") {
|
|
817
|
+
const text = options.rest.join(" ").trim();
|
|
818
|
+
if (!text) throw new Error("Tell me what to remember, like moondog remember no live albums.");
|
|
819
|
+
const value = application.rememberMemory({ text, kind: "fact" });
|
|
820
|
+
process.stdout.write(
|
|
821
|
+
`${options.json ? JSON.stringify(value, null, 2) : `Remembered ${value.memory_id}: ${value.text}`}\n`,
|
|
822
|
+
);
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
if (options.command === "forget") {
|
|
827
|
+
const memoryId = options.rest[0]?.trim();
|
|
828
|
+
if (!memoryId || options.rest.length !== 1) {
|
|
829
|
+
throw new Error("Usage: moondog forget <memory-id>.");
|
|
830
|
+
}
|
|
831
|
+
const value = application.forgetMemory(memoryId);
|
|
832
|
+
process.stdout.write(
|
|
833
|
+
`${options.json ? JSON.stringify(value, null, 2) : value.forgotten ? `Forgot ${memoryId}.` : `I don't have a memory called ${memoryId}.`}\n`,
|
|
834
|
+
);
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
if (options.command === "ask") {
|
|
839
|
+
const prompt = options.rest.join(" ").trim();
|
|
840
|
+
if (!prompt) throw new Error("Ask me something, like moondog ask \"what should I play tonight?\"");
|
|
841
|
+
if (runtimeStatus.state !== "configured") {
|
|
842
|
+
if (runtimeStatus.reason === "provider_authentication_required") {
|
|
843
|
+
throw new Error(
|
|
844
|
+
`You're not signed in to ${runtimeStatus.provider} yet. Run moondog auth login ${runtimeStatus.provider} first.`,
|
|
845
|
+
);
|
|
846
|
+
}
|
|
847
|
+
throw new Error(
|
|
848
|
+
"No model is connected. Run moondog and use /model, or set MOONDOG_PROVIDER and MOONDOG_MODEL.",
|
|
849
|
+
);
|
|
850
|
+
}
|
|
851
|
+
const result = await runtime.prompt(prompt);
|
|
852
|
+
if (options.json) {
|
|
853
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
854
|
+
} else {
|
|
855
|
+
process.stdout.write(`${sanitizeTerminalText(result.text)}\n`);
|
|
856
|
+
}
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
if (options.command) {
|
|
861
|
+
throw new Error(`Unknown command: ${options.command}`);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
865
|
+
throw new Error(
|
|
866
|
+
"The listening room needs an interactive terminal. For a quick check without one, try moondog status.",
|
|
867
|
+
);
|
|
868
|
+
}
|
|
869
|
+
lyrics = await createLyricService().catch(() => null);
|
|
870
|
+
await runMoondogTui({
|
|
871
|
+
application,
|
|
872
|
+
runtime,
|
|
873
|
+
lyrics,
|
|
874
|
+
prepareImport: prepareTuiHistoryImport,
|
|
875
|
+
prepareRecentImport: async () => prepareTuiHistoryImport(undefined, {
|
|
876
|
+
recentPage: await runSpotifySurface({
|
|
877
|
+
args: ["recent", "--limit", "50"], stdout: { write() {} },
|
|
878
|
+
}),
|
|
879
|
+
}),
|
|
880
|
+
openImportHelp,
|
|
881
|
+
refreshImportedData: async (receipt) => {
|
|
882
|
+
if (receipt?.provider === "apple-music-library") await refreshAppleLibraryImport();
|
|
883
|
+
const next = await loadDomainServices();
|
|
884
|
+
const previous = application.domainServices;
|
|
885
|
+
application.domainServices = next.domainServices;
|
|
886
|
+
application.domainServicesError = next.domainServicesError;
|
|
887
|
+
previous?.close?.();
|
|
888
|
+
},
|
|
889
|
+
rebuildRuntime: async (selection) => {
|
|
890
|
+
if (selection) await writePiRuntimeSelection(selection);
|
|
891
|
+
return createConfiguredRuntime(application, process.env, { selection });
|
|
892
|
+
},
|
|
893
|
+
runAuth: (provider) =>
|
|
894
|
+
runAuthCommand({
|
|
895
|
+
args: ["login", provider],
|
|
896
|
+
}),
|
|
897
|
+
runWeb: (args, options) => runWebCommand({ args, webResearch, ...options }),
|
|
898
|
+
runProfileAction: (args) => runProfileCommand({
|
|
899
|
+
args,
|
|
900
|
+
commandPrefix: "/profile",
|
|
901
|
+
resolvePreferredSubjectId: optionalAppleMusicSubjectId,
|
|
902
|
+
output: { write() {} },
|
|
903
|
+
}),
|
|
904
|
+
runProfile: async (args) => {
|
|
905
|
+
const values = [...args];
|
|
906
|
+
const json = values.at(-1) === "--json";
|
|
907
|
+
if (json) values.pop();
|
|
908
|
+
let output = "";
|
|
909
|
+
await runProfileCommand({
|
|
910
|
+
args: values,
|
|
911
|
+
json,
|
|
912
|
+
commandPrefix: "/profile",
|
|
913
|
+
resolvePreferredSubjectId: optionalAppleMusicSubjectId,
|
|
914
|
+
output: { write: (value) => { output += String(value); } },
|
|
915
|
+
});
|
|
916
|
+
return json ? `\`\`\`json\n${output.trim()}\n\`\`\`` : output.trim();
|
|
917
|
+
},
|
|
918
|
+
runSpotify: async (args, { signal } = {}) => {
|
|
919
|
+
let output = "";
|
|
920
|
+
const capture = {
|
|
921
|
+
write(value) {
|
|
922
|
+
output += String(value);
|
|
923
|
+
},
|
|
924
|
+
};
|
|
925
|
+
await runSpotifySurface({
|
|
926
|
+
args,
|
|
927
|
+
signal,
|
|
928
|
+
stdout: capture,
|
|
929
|
+
stderr: args[0] === "login" ? process.stderr : capture,
|
|
930
|
+
resolutionCache: await spotifyResolutionCacheIfConfigured(),
|
|
931
|
+
});
|
|
932
|
+
if (["import-history", "sync-recent"].includes(args[0])) {
|
|
933
|
+
const next = await loadDomainServices();
|
|
934
|
+
const previous = application.domainServices;
|
|
935
|
+
application.domainServices = next.domainServices;
|
|
936
|
+
application.domainServicesError = next.domainServicesError;
|
|
937
|
+
previous?.close?.();
|
|
938
|
+
}
|
|
939
|
+
application.setSpotifyConnection(await loadSpotifyConnection());
|
|
940
|
+
return output.trim();
|
|
941
|
+
},
|
|
942
|
+
});
|
|
943
|
+
} finally {
|
|
944
|
+
lyrics?.close();
|
|
945
|
+
application.close();
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
main()
|
|
950
|
+
.catch((error) => {
|
|
951
|
+
process.stderr.write(`moondog: ${sanitizeTerminalText(error.message)}\n`);
|
|
952
|
+
process.exitCode = 1;
|
|
953
|
+
})
|
|
954
|
+
.finally(() => closeSpotifyResolutionCache());
|