@audiofool/moondog 0.1.0 → 0.2.0-beta.3
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/LICENSE +202 -0
- package/README.md +156 -68
- package/contracts/v1/definitions.schema.json +1 -169
- package/package.json +7 -23
- package/scripts/contract-lib.mjs +0 -7
- package/scripts/contract-semantics.mjs +0 -1904
- package/scripts/moondog.mjs +285 -74
- package/src/core/apple-library-source-status.mjs +0 -16
- package/src/core/apple-projection-domain-services.mjs +253 -53
- package/src/core/application-update.mjs +320 -0
- package/src/core/capability-catalog.mjs +290 -55
- package/src/core/listener-avoids.mjs +101 -0
- package/src/core/listening-now.mjs +108 -0
- package/src/core/listening-profile-domain-services.mjs +189 -41
- package/src/core/listening-upkeep.mjs +31 -0
- package/src/core/moondog-application.mjs +2115 -107
- package/src/core/playback-preferences.mjs +97 -0
- package/src/core/spotify-listening-intent.mjs +124 -0
- package/src/core/synthetic-domain-services.mjs +24 -3
- package/src/core/track-title.mjs +107 -0
- package/src/demo/moondog-demo.mjs +4 -22
- package/src/i18n/index.mjs +285 -0
- package/src/i18n/messages/de.mjs +135 -0
- package/src/i18n/messages/en.mjs +136 -0
- package/src/i18n/messages/es.mjs +135 -0
- package/src/i18n/messages/fr.mjs +135 -0
- package/src/i18n/messages/it.mjs +135 -0
- package/src/i18n/messages/ja.mjs +135 -0
- package/src/i18n/messages/ko.mjs +135 -0
- package/src/i18n/messages/pt.mjs +135 -0
- package/src/i18n/messages/zh-Hant.mjs +135 -0
- package/src/i18n/messages/zh.mjs +135 -0
- package/src/i18n/preferences.mjs +34 -0
- package/src/i18n/screen/es.mjs +967 -0
- package/src/i18n/screen/ja.mjs +967 -0
- package/src/i18n/screen/pt.mjs +967 -0
- package/src/i18n/screen/zh.mjs +967 -0
- package/src/importers/apple-music-library/index.mjs +0 -16
- package/src/importers/apple-music-library/sqlite-projection.mjs +51 -9
- package/src/integrations/apple-music/catalog.mjs +166 -14
- package/src/integrations/apple-music/music-app.mjs +232 -0
- package/src/integrations/apple-music/storefront.mjs +28 -0
- package/src/integrations/apple-music/terminal-preview-player.mjs +221 -0
- package/src/integrations/lastfm/history.mjs +221 -0
- package/src/integrations/lastfm/settings.mjs +75 -0
- package/src/integrations/netease/ncm-cli-player.mjs +328 -0
- package/src/integrations/open-music-similarity/artist-radio.mjs +26 -18
- package/src/integrations/public-playlists.mjs +2 -1
- package/src/integrations/spotify/account-data-profile.mjs +14 -6
- package/src/integrations/spotify/authentication.mjs +149 -15
- package/src/integrations/spotify/catalog-resolver.mjs +9 -6
- package/src/integrations/spotify/connection.mjs +2 -1
- package/src/integrations/spotify/credential-store.mjs +4 -0
- package/src/integrations/spotify/extended-streaming-history.mjs +11 -11
- package/src/integrations/spotify/history-archive.mjs +1 -1
- package/src/integrations/spotify/library-snapshot.mjs +227 -0
- package/src/integrations/spotify/recent-activity.mjs +2 -0
- package/src/integrations/spotify/service.mjs +873 -69
- package/src/integrations/spotify/web-api-client.mjs +410 -127
- package/src/integrations/web/brave-web.mjs +45 -0
- package/src/integrations/web/codex-web.mjs +15 -82
- package/src/integrations/web/deepseek-web.mjs +81 -0
- package/src/integrations/web/web-research.mjs +119 -0
- package/src/integrations/web/web-result.mjs +215 -0
- package/src/integrations/youtube-music/takeout.mjs +3 -1
- package/src/memory/local-memory-store.mjs +542 -57
- package/src/profile/history-import.mjs +42 -6
- package/src/profile/listener-corrections.mjs +22 -0
- package/src/profile/listener-profile-build.mjs +461 -0
- package/src/profile/listener-profile-store.mjs +130 -0
- package/src/profile/listener-profile-verification.mjs +68 -0
- package/src/profile/listener-profile-worker.mjs +179 -0
- package/src/profile/listening-history-store.mjs +640 -17
- package/src/profile/listening-profile-projection.mjs +454 -86
- package/src/profile/local-music-data.mjs +1 -1
- package/src/profile/moondog-suggestions.mjs +184 -0
- package/src/profile/music-providers.mjs +11 -7
- package/src/profile/profile-exploration.mjs +92 -0
- package/src/profile/recorded-measures.mjs +24 -0
- package/src/profile/spotify-archive-taste.mjs +0 -4
- package/src/runtime/pi/agent-runtime.mjs +2536 -692
- package/src/runtime/pi/authentication.mjs +34 -1
- package/src/runtime/pi/configured-runtime.mjs +22 -6
- package/src/runtime/pi/conversation-name.mjs +53 -0
- package/src/runtime/pi/memory-agent-runtime.mjs +2 -2
- package/src/runtime/pi/model-catalog.mjs +28 -5
- package/src/runtime/pi/model-settings.mjs +202 -0
- package/src/runtime/pi/model-transport.mjs +10 -0
- package/src/runtime/pi/persistent-credential-store.mjs +67 -31
- package/src/runtime/pi/profile-build-runtime.mjs +161 -0
- package/src/runtime/pi/public-model-catalog.mjs +288 -0
- package/src/runtime/pi/runtime-settings.mjs +10 -2
- package/src/runtime/pi/skills/listener-profile/SKILL.md +48 -0
- package/src/runtime/pi/tool-trace.mjs +37 -0
- package/src/surfaces/cli/auth-command.mjs +12 -4
- package/src/surfaces/cli/brand-components.mjs +310 -132
- package/src/surfaces/cli/brand-theme.mjs +51 -28
- package/src/surfaces/cli/command-completions.mjs +58 -42
- package/src/surfaces/cli/format-output.mjs +199 -388
- package/src/surfaces/cli/history-import-view.mjs +292 -139
- package/src/surfaces/cli/import-guides.mjs +2 -0
- package/src/surfaces/cli/listener-profile-command.mjs +92 -0
- package/src/surfaces/cli/listening-menu.mjs +7 -4
- package/src/surfaces/cli/profile-command.mjs +30 -21
- package/src/surfaces/cli/seasons-chart.mjs +87 -0
- package/src/surfaces/cli/spotify-command.mjs +54 -6
- package/src/surfaces/cli/taste-profile-model.mjs +78 -79
- package/src/surfaces/cli/taste-profile-view.mjs +33 -26
- package/src/surfaces/cli/terminal-art.mjs +140 -35
- package/src/surfaces/cli/tui.mjs +1066 -357
- package/src/surfaces/cli/update-command.mjs +32 -0
- package/src/surfaces/cli/web-command.mjs +53 -14
- package/src/surfaces/html/tasteprint.mjs +43 -70
- package/contracts/v1/capability-policy.schema.json +0 -572
- package/contracts/v1/profile-evidence.schema.json +0 -237
- package/contracts/v1/tool-invocation.schema.json +0 -318
- package/contracts/v1/tool-receipt.schema.json +0 -1066
- package/scripts/demo-studio.mjs +0 -58
- package/src/core/apple-data-migration.mjs +0 -50
- package/src/demo/moondog-interactive-tasteprint-demo.mjs +0 -293
- package/src/demo/moondog-tasteprint-demo.mjs +0 -631
- package/src/integrations/spotify/index.mjs +0 -80
- package/src/surfaces/web/showcase.mjs +0 -272
- package/src/surfaces/web/studio-page.mjs +0 -4148
- package/src/surfaces/web/studio.mjs +0 -2288
|
@@ -1,2288 +0,0 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
2
|
-
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
3
|
-
import {
|
|
4
|
-
access,
|
|
5
|
-
chmod,
|
|
6
|
-
mkdir,
|
|
7
|
-
mkdtemp,
|
|
8
|
-
open,
|
|
9
|
-
readFile,
|
|
10
|
-
rm,
|
|
11
|
-
stat,
|
|
12
|
-
} from "node:fs/promises";
|
|
13
|
-
import { createServer } from "node:http";
|
|
14
|
-
import { tmpdir } from "node:os";
|
|
15
|
-
import path from "node:path";
|
|
16
|
-
import { fileURLToPath } from "node:url";
|
|
17
|
-
|
|
18
|
-
import {
|
|
19
|
-
createPublicTasteprintDemoProfile,
|
|
20
|
-
PUBLIC_TASTEPRINT_DEMO_GENERATED_AT,
|
|
21
|
-
} from "../../demo/moondog-tasteprint-demo.mjs";
|
|
22
|
-
import { InteractiveTasteprintDemoSession } from "../../demo/moondog-interactive-tasteprint-demo.mjs";
|
|
23
|
-
import { writeFictionalSpotifyHistoryArchive } from "../../demo/fictional-spotify-history.mjs";
|
|
24
|
-
import {
|
|
25
|
-
LISTENBRAINZ_HISTORY_LIMITS,
|
|
26
|
-
readListenBrainzHistoryFile,
|
|
27
|
-
} from "../../integrations/listenbrainz/history-file.mjs";
|
|
28
|
-
import {
|
|
29
|
-
createListeningHistoryProfileProjection,
|
|
30
|
-
persistTasteFromSpotifyArchive,
|
|
31
|
-
projectTasteFromSpotifyArchives,
|
|
32
|
-
projectTasteFromSpotifyArchive,
|
|
33
|
-
SPOTIFY_ARCHIVE_SET_MAXIMUM,
|
|
34
|
-
} from "../../profile/spotify-archive-taste.mjs";
|
|
35
|
-
import {
|
|
36
|
-
openListeningHistoryStore,
|
|
37
|
-
resolveListeningHistoryPath,
|
|
38
|
-
} from "../../profile/listening-history-store.mjs";
|
|
39
|
-
import { writeTasteprintArtifact } from "../html/tasteprint-artifact.mjs";
|
|
40
|
-
import {
|
|
41
|
-
renderTasteprintCardHtml,
|
|
42
|
-
renderTasteprintHtml,
|
|
43
|
-
} from "../html/tasteprint.mjs";
|
|
44
|
-
import { renderStudioPage } from "./studio-page.mjs";
|
|
45
|
-
|
|
46
|
-
export const MOONDOG_STUDIO_HOST = "127.0.0.1";
|
|
47
|
-
export const MOONDOG_STUDIO_MAXIMUM_ARCHIVES = SPOTIFY_ARCHIVE_SET_MAXIMUM;
|
|
48
|
-
export const MOONDOG_STUDIO_MAXIMUM_ARCHIVE_BYTES = 512 * 1024 * 1024;
|
|
49
|
-
export const MOONDOG_STUDIO_MAXIMUM_HISTORY_JSON_BYTES =
|
|
50
|
-
LISTENBRAINZ_HISTORY_LIMITS.maximumFileBytes;
|
|
51
|
-
export const MOONDOG_STUDIO_MAXIMUM_JSON_BYTES = 16 * 1024;
|
|
52
|
-
|
|
53
|
-
const repositoryRoot = path.resolve(
|
|
54
|
-
path.dirname(fileURLToPath(import.meta.url)),
|
|
55
|
-
"../../..",
|
|
56
|
-
);
|
|
57
|
-
const defaultBrandImagePath = path.join(
|
|
58
|
-
repositoryRoot,
|
|
59
|
-
"assets",
|
|
60
|
-
"brand",
|
|
61
|
-
"moondog-lunar-record",
|
|
62
|
-
"moondog-logo-1x1.png",
|
|
63
|
-
);
|
|
64
|
-
const zipSignatures = new Set(["504b0304", "504b0506", "504b0708"]);
|
|
65
|
-
const fictionalHistoryCapturedAt = "2026-08-25T23:59:59.000Z";
|
|
66
|
-
|
|
67
|
-
class StudioHttpError extends Error {
|
|
68
|
-
constructor(statusCode, message) {
|
|
69
|
-
super(message);
|
|
70
|
-
this.name = "StudioHttpError";
|
|
71
|
-
this.statusCode = statusCode;
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function sessionSecret() {
|
|
76
|
-
return randomBytes(32).toString("base64url");
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function validToken(candidate, expected) {
|
|
80
|
-
if (typeof candidate !== "string") return false;
|
|
81
|
-
const left = Buffer.from(candidate, "utf8");
|
|
82
|
-
const right = Buffer.from(expected, "utf8");
|
|
83
|
-
return left.length === right.length && timingSafeEqual(left, right);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function baseHeaders(contentType) {
|
|
87
|
-
return {
|
|
88
|
-
"Cache-Control": "no-store, max-age=0",
|
|
89
|
-
"Content-Type": contentType,
|
|
90
|
-
"Cross-Origin-Opener-Policy": "same-origin",
|
|
91
|
-
"Cross-Origin-Resource-Policy": "same-origin",
|
|
92
|
-
"Permissions-Policy": "camera=(), microphone=(), geolocation=(), payment=()",
|
|
93
|
-
"Referrer-Policy": "no-referrer",
|
|
94
|
-
"X-Content-Type-Options": "nosniff",
|
|
95
|
-
"X-Frame-Options": "DENY",
|
|
96
|
-
};
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function send(response, statusCode, body, headers = {}) {
|
|
100
|
-
const buffer = Buffer.isBuffer(body) ? body : Buffer.from(String(body));
|
|
101
|
-
response.writeHead(statusCode, {
|
|
102
|
-
...headers,
|
|
103
|
-
"Content-Length": buffer.length,
|
|
104
|
-
});
|
|
105
|
-
response.end(buffer);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function sendJson(response, statusCode, value) {
|
|
109
|
-
send(response, statusCode, `${JSON.stringify(value)}\n`, {
|
|
110
|
-
...baseHeaders("application/json; charset=utf-8"),
|
|
111
|
-
"Content-Security-Policy": "default-src 'none'; frame-ancestors 'none'",
|
|
112
|
-
});
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function sendNotFound(response) {
|
|
116
|
-
send(response, 404, "Not found.\n", {
|
|
117
|
-
...baseHeaders("text/plain; charset=utf-8"),
|
|
118
|
-
"Content-Security-Policy": "default-src 'none'; frame-ancestors 'none'",
|
|
119
|
-
});
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function archiveContentLength(request) {
|
|
123
|
-
const raw = request.headers["content-length"];
|
|
124
|
-
if (raw === undefined) return null;
|
|
125
|
-
if (Array.isArray(raw) || !/^[0-9]+$/u.test(raw)) {
|
|
126
|
-
throw new StudioHttpError(400, "The ZIP upload length is invalid.");
|
|
127
|
-
}
|
|
128
|
-
const value = Number(raw);
|
|
129
|
-
if (!Number.isSafeInteger(value)) {
|
|
130
|
-
throw new StudioHttpError(413, "That ZIP is larger than Studio supports.");
|
|
131
|
-
}
|
|
132
|
-
return value;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
function historyJsonContentLength(request) {
|
|
136
|
-
const raw = request.headers["content-length"];
|
|
137
|
-
if (raw === undefined) return null;
|
|
138
|
-
if (Array.isArray(raw) || !/^[0-9]+$/u.test(raw)) {
|
|
139
|
-
throw new StudioHttpError(400, "The ListenBrainz JSON upload length is invalid.");
|
|
140
|
-
}
|
|
141
|
-
const value = Number(raw);
|
|
142
|
-
if (!Number.isSafeInteger(value)) {
|
|
143
|
-
throw new StudioHttpError(
|
|
144
|
-
413,
|
|
145
|
-
"That ListenBrainz JSON is larger than Studio supports.",
|
|
146
|
-
);
|
|
147
|
-
}
|
|
148
|
-
return value;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
function requestContentLength(request, maximumBytes) {
|
|
152
|
-
const raw = request.headers["content-length"];
|
|
153
|
-
if (raw === undefined) return null;
|
|
154
|
-
if (Array.isArray(raw) || !/^[0-9]+$/u.test(raw)) {
|
|
155
|
-
throw new StudioHttpError(400, "The local profile update length is invalid.");
|
|
156
|
-
}
|
|
157
|
-
const value = Number(raw);
|
|
158
|
-
if (!Number.isSafeInteger(value) || value > maximumBytes) {
|
|
159
|
-
throw new StudioHttpError(413, "That local profile update is too large.");
|
|
160
|
-
}
|
|
161
|
-
return value;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
async function receiveJson(request, maximumBytes = MOONDOG_STUDIO_MAXIMUM_JSON_BYTES) {
|
|
165
|
-
requestContentLength(request, maximumBytes);
|
|
166
|
-
const chunks = [];
|
|
167
|
-
let bytes = 0;
|
|
168
|
-
for await (const value of request) {
|
|
169
|
-
const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value);
|
|
170
|
-
bytes += chunk.length;
|
|
171
|
-
if (bytes > maximumBytes) {
|
|
172
|
-
throw new StudioHttpError(413, "That local profile update is too large.");
|
|
173
|
-
}
|
|
174
|
-
chunks.push(chunk);
|
|
175
|
-
}
|
|
176
|
-
if (bytes === 0) {
|
|
177
|
-
throw new StudioHttpError(400, "The local profile update is empty.");
|
|
178
|
-
}
|
|
179
|
-
let value;
|
|
180
|
-
try {
|
|
181
|
-
value = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
182
|
-
} catch {
|
|
183
|
-
throw new StudioHttpError(400, "The local profile update is not valid JSON.");
|
|
184
|
-
}
|
|
185
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
186
|
-
throw new StudioHttpError(422, "The local profile update is invalid.");
|
|
187
|
-
}
|
|
188
|
-
return value;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
function exactObject(value, allowedKeys) {
|
|
192
|
-
if (Object.keys(value).some((key) => !allowedKeys.has(key))) {
|
|
193
|
-
throw new StudioHttpError(422, "The local profile update has unknown fields.");
|
|
194
|
-
}
|
|
195
|
-
return value;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
function listenerCorrectionInput(value) {
|
|
199
|
-
exactObject(
|
|
200
|
-
value,
|
|
201
|
-
new Set([
|
|
202
|
-
"entity_type",
|
|
203
|
-
"label",
|
|
204
|
-
"artist_credit",
|
|
205
|
-
"stance",
|
|
206
|
-
"note",
|
|
207
|
-
]),
|
|
208
|
-
);
|
|
209
|
-
if (value.entity_type === "artist" && "artist_credit" in value) {
|
|
210
|
-
throw new StudioHttpError(
|
|
211
|
-
422,
|
|
212
|
-
"Artist corrections do not use a separate artist credit.",
|
|
213
|
-
);
|
|
214
|
-
}
|
|
215
|
-
return {
|
|
216
|
-
entityType: value.entity_type,
|
|
217
|
-
label: value.label,
|
|
218
|
-
artistCredit: value.artist_credit,
|
|
219
|
-
stance: value.stance,
|
|
220
|
-
...(
|
|
221
|
-
typeof value.note === "string" && value.note.trim() === ""
|
|
222
|
-
? {}
|
|
223
|
-
: { note: value.note }
|
|
224
|
-
),
|
|
225
|
-
};
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
async function pathExists(filePath) {
|
|
229
|
-
try {
|
|
230
|
-
await access(filePath);
|
|
231
|
-
return true;
|
|
232
|
-
} catch (error) {
|
|
233
|
-
if (error?.code === "ENOENT") return false;
|
|
234
|
-
throw error;
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
async function createFictionalHistoryDemo({
|
|
239
|
-
temporaryRoot,
|
|
240
|
-
writeArchive = writeFictionalSpotifyHistoryArchive,
|
|
241
|
-
projectArchive = projectTasteFromSpotifyArchive,
|
|
242
|
-
} = {}) {
|
|
243
|
-
await mkdir(temporaryRoot, { recursive: true });
|
|
244
|
-
const demoRoot = await mkdtemp(
|
|
245
|
-
path.join(temporaryRoot, "moondog-studio-fictional-history-"),
|
|
246
|
-
);
|
|
247
|
-
if (process.platform !== "win32") await chmod(demoRoot, 0o700);
|
|
248
|
-
const archivePath = path.join(demoRoot, "moondog-fictional-history.zip");
|
|
249
|
-
try {
|
|
250
|
-
const manifest = await writeArchive({ outputPath: archivePath });
|
|
251
|
-
const projection = await projectArchive({
|
|
252
|
-
archivePath,
|
|
253
|
-
capturedAt: fictionalHistoryCapturedAt,
|
|
254
|
-
maxItems: 10,
|
|
255
|
-
});
|
|
256
|
-
const profile = structuredClone(projection.profile);
|
|
257
|
-
profile.limitations = [
|
|
258
|
-
"This fictional Studio profile was generated through the production Spotify history importer and exists only in this process.",
|
|
259
|
-
...(profile.limitations ?? []),
|
|
260
|
-
].slice(0, 8);
|
|
261
|
-
return {
|
|
262
|
-
profile,
|
|
263
|
-
proof: {
|
|
264
|
-
kind: "fictional_spotify_extended_history",
|
|
265
|
-
archive_schema: manifest.schema,
|
|
266
|
-
source_format: projection.source.source_format,
|
|
267
|
-
record_count: projection.source.input_records,
|
|
268
|
-
years: manifest.years,
|
|
269
|
-
production_importer: true,
|
|
270
|
-
private_listener_data_read: false,
|
|
271
|
-
persistent_profile_writes: false,
|
|
272
|
-
},
|
|
273
|
-
};
|
|
274
|
-
} finally {
|
|
275
|
-
await rm(demoRoot, { recursive: true, force: true });
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
async function createPrivateArchiveSession({
|
|
280
|
-
archivePaths,
|
|
281
|
-
maximumArchiveBytes,
|
|
282
|
-
capturedAt,
|
|
283
|
-
projectArchive = projectTasteFromSpotifyArchive,
|
|
284
|
-
projectArchives = projectTasteFromSpotifyArchives,
|
|
285
|
-
} = {}) {
|
|
286
|
-
let totalArchiveBytes = 0;
|
|
287
|
-
for (const archivePath of archivePaths) {
|
|
288
|
-
let archiveStats;
|
|
289
|
-
try {
|
|
290
|
-
archiveStats = await stat(archivePath);
|
|
291
|
-
} catch {
|
|
292
|
-
throw new StudioHttpError(
|
|
293
|
-
422,
|
|
294
|
-
"A selected Spotify history ZIP could not be read safely.",
|
|
295
|
-
);
|
|
296
|
-
}
|
|
297
|
-
if (!archiveStats.isFile() || archiveStats.size < 4) {
|
|
298
|
-
throw new StudioHttpError(
|
|
299
|
-
422,
|
|
300
|
-
"A selected Spotify history ZIP could not be read safely.",
|
|
301
|
-
);
|
|
302
|
-
}
|
|
303
|
-
totalArchiveBytes += archiveStats.size;
|
|
304
|
-
}
|
|
305
|
-
if (totalArchiveBytes > maximumArchiveBytes) {
|
|
306
|
-
throw new StudioHttpError(
|
|
307
|
-
413,
|
|
308
|
-
"The selected ZIP archives are larger than Studio supports together.",
|
|
309
|
-
);
|
|
310
|
-
}
|
|
311
|
-
try {
|
|
312
|
-
const projection = archivePaths.length === 1
|
|
313
|
-
? await projectArchive({
|
|
314
|
-
archivePath: archivePaths[0],
|
|
315
|
-
capturedAt,
|
|
316
|
-
maxItems: 10,
|
|
317
|
-
})
|
|
318
|
-
: await projectArchives({
|
|
319
|
-
archivePaths,
|
|
320
|
-
capturedAt,
|
|
321
|
-
maxItems: 10,
|
|
322
|
-
});
|
|
323
|
-
return {
|
|
324
|
-
profile: structuredClone(projection.profile),
|
|
325
|
-
source: {
|
|
326
|
-
provider: "spotify",
|
|
327
|
-
data_scope: projection.source.data_scope,
|
|
328
|
-
input_records: projection.source.input_records,
|
|
329
|
-
persistent_import: false,
|
|
330
|
-
...(projection.source.archive_count > 1
|
|
331
|
-
? {
|
|
332
|
-
archive_count: projection.source.archive_count,
|
|
333
|
-
sources: projection.source.sources,
|
|
334
|
-
effective_listening_events:
|
|
335
|
-
projection.source.effective_listening_events,
|
|
336
|
-
reconciled_overlap_events:
|
|
337
|
-
projection.source.reconciled_overlap_events,
|
|
338
|
-
}
|
|
339
|
-
: {}),
|
|
340
|
-
},
|
|
341
|
-
};
|
|
342
|
-
} catch (error) {
|
|
343
|
-
throw publicImportError(error);
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
function selectedSpotifyArchivePaths({ archivePath, archivePaths }) {
|
|
348
|
-
if (archivePath !== null && archivePaths !== null) {
|
|
349
|
-
throw new TypeError(
|
|
350
|
-
"Moondog Studio accepts archivePath or archivePaths, not both.",
|
|
351
|
-
);
|
|
352
|
-
}
|
|
353
|
-
const selected = archivePaths ?? (archivePath === null ? [] : [archivePath]);
|
|
354
|
-
if (
|
|
355
|
-
!Array.isArray(selected) ||
|
|
356
|
-
selected.length > SPOTIFY_ARCHIVE_SET_MAXIMUM ||
|
|
357
|
-
selected.some((item) => typeof item !== "string" || !item.trim())
|
|
358
|
-
) {
|
|
359
|
-
throw new TypeError("Moondog Studio archive paths are invalid.");
|
|
360
|
-
}
|
|
361
|
-
const normalized = selected.map((item) => item.trim());
|
|
362
|
-
if (new Set(normalized).size !== normalized.length) {
|
|
363
|
-
throw new TypeError("Moondog Studio archive paths must be distinct.");
|
|
364
|
-
}
|
|
365
|
-
return normalized;
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
async function receivePrivateZip(
|
|
369
|
-
request,
|
|
370
|
-
{ maximumArchiveBytes, temporaryRoot },
|
|
371
|
-
) {
|
|
372
|
-
const contentLength = archiveContentLength(request);
|
|
373
|
-
if (contentLength !== null && contentLength > maximumArchiveBytes) {
|
|
374
|
-
throw new StudioHttpError(413, "That ZIP is larger than Studio supports.");
|
|
375
|
-
}
|
|
376
|
-
if (contentLength !== null && contentLength < 4) {
|
|
377
|
-
throw new StudioHttpError(422, "That file is not a readable Spotify ZIP.");
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
await mkdir(temporaryRoot, { recursive: true });
|
|
381
|
-
const uploadRoot = await mkdtemp(path.join(temporaryRoot, "moondog-studio-"));
|
|
382
|
-
if (process.platform !== "win32") await chmod(uploadRoot, 0o700);
|
|
383
|
-
const archivePath = path.join(uploadRoot, "spotify-history.zip");
|
|
384
|
-
let handle;
|
|
385
|
-
try {
|
|
386
|
-
handle = await open(archivePath, "wx", 0o600);
|
|
387
|
-
let bytes = 0;
|
|
388
|
-
let oversized = false;
|
|
389
|
-
const signature = Buffer.alloc(4);
|
|
390
|
-
let signatureBytes = 0;
|
|
391
|
-
for await (const value of request) {
|
|
392
|
-
const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value);
|
|
393
|
-
bytes += chunk.length;
|
|
394
|
-
if (signatureBytes < signature.length) {
|
|
395
|
-
const copyBytes = Math.min(signature.length - signatureBytes, chunk.length);
|
|
396
|
-
chunk.copy(signature, signatureBytes, 0, copyBytes);
|
|
397
|
-
signatureBytes += copyBytes;
|
|
398
|
-
}
|
|
399
|
-
if (bytes > maximumArchiveBytes) {
|
|
400
|
-
oversized = true;
|
|
401
|
-
continue;
|
|
402
|
-
}
|
|
403
|
-
await handle.writeFile(chunk);
|
|
404
|
-
}
|
|
405
|
-
await handle.close();
|
|
406
|
-
handle = null;
|
|
407
|
-
if (oversized) {
|
|
408
|
-
throw new StudioHttpError(413, "That ZIP is larger than Studio supports.");
|
|
409
|
-
}
|
|
410
|
-
if (bytes < 4 || !zipSignatures.has(signature.toString("hex"))) {
|
|
411
|
-
throw new StudioHttpError(422, "That file is not a readable Spotify ZIP.");
|
|
412
|
-
}
|
|
413
|
-
if (process.platform !== "win32") await chmod(archivePath, 0o600);
|
|
414
|
-
return { archivePath, uploadRoot };
|
|
415
|
-
} catch (error) {
|
|
416
|
-
await handle?.close().catch(() => {});
|
|
417
|
-
await rm(uploadRoot, { recursive: true, force: true });
|
|
418
|
-
throw error;
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
async function receivePrivateListenBrainzJson(
|
|
423
|
-
request,
|
|
424
|
-
{ maximumHistoryJsonBytes, temporaryRoot },
|
|
425
|
-
) {
|
|
426
|
-
const contentLength = historyJsonContentLength(request);
|
|
427
|
-
if (contentLength !== null && contentLength > maximumHistoryJsonBytes) {
|
|
428
|
-
throw new StudioHttpError(
|
|
429
|
-
413,
|
|
430
|
-
"That ListenBrainz JSON is larger than Studio supports.",
|
|
431
|
-
);
|
|
432
|
-
}
|
|
433
|
-
if (contentLength === 0) {
|
|
434
|
-
throw new StudioHttpError(422, "That file is not readable ListenBrainz JSON.");
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
await mkdir(temporaryRoot, { recursive: true });
|
|
438
|
-
const uploadRoot = await mkdtemp(path.join(temporaryRoot, "moondog-studio-"));
|
|
439
|
-
if (process.platform !== "win32") await chmod(uploadRoot, 0o700);
|
|
440
|
-
const historyPath = path.join(uploadRoot, "listenbrainz-history.json");
|
|
441
|
-
let handle;
|
|
442
|
-
try {
|
|
443
|
-
handle = await open(historyPath, "wx", 0o600);
|
|
444
|
-
let bytes = 0;
|
|
445
|
-
let oversized = false;
|
|
446
|
-
for await (const value of request) {
|
|
447
|
-
const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value);
|
|
448
|
-
bytes += chunk.length;
|
|
449
|
-
if (bytes > maximumHistoryJsonBytes) {
|
|
450
|
-
oversized = true;
|
|
451
|
-
continue;
|
|
452
|
-
}
|
|
453
|
-
await handle.writeFile(chunk);
|
|
454
|
-
}
|
|
455
|
-
await handle.close();
|
|
456
|
-
handle = null;
|
|
457
|
-
if (oversized) {
|
|
458
|
-
throw new StudioHttpError(
|
|
459
|
-
413,
|
|
460
|
-
"That ListenBrainz JSON is larger than Studio supports.",
|
|
461
|
-
);
|
|
462
|
-
}
|
|
463
|
-
if (bytes === 0) {
|
|
464
|
-
throw new StudioHttpError(
|
|
465
|
-
422,
|
|
466
|
-
"That file is not readable ListenBrainz JSON.",
|
|
467
|
-
);
|
|
468
|
-
}
|
|
469
|
-
if (process.platform !== "win32") await chmod(historyPath, 0o600);
|
|
470
|
-
return { historyPath, uploadRoot };
|
|
471
|
-
} catch (error) {
|
|
472
|
-
await handle?.close().catch(() => {});
|
|
473
|
-
await rm(uploadRoot, { recursive: true, force: true });
|
|
474
|
-
throw error;
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
async function persistTasteFromListenBrainzHistory({
|
|
479
|
-
historyPath,
|
|
480
|
-
subjectId,
|
|
481
|
-
store,
|
|
482
|
-
capturedAt,
|
|
483
|
-
maxItems,
|
|
484
|
-
historyFileImporter = readListenBrainzHistoryFile,
|
|
485
|
-
}) {
|
|
486
|
-
const bundle = await historyFileImporter({
|
|
487
|
-
filePath: historyPath,
|
|
488
|
-
subjectId,
|
|
489
|
-
capturedAt,
|
|
490
|
-
});
|
|
491
|
-
const receipt = store.ingestImport(bundle);
|
|
492
|
-
const listening = store.profileSummary({ subjectId, maxItems });
|
|
493
|
-
return {
|
|
494
|
-
profile: createListeningHistoryProfileProjection(listening, { maxItems }),
|
|
495
|
-
source: {
|
|
496
|
-
source_format: receipt.source_format,
|
|
497
|
-
data_scope: receipt.data_scope,
|
|
498
|
-
input_records: receipt.input_records,
|
|
499
|
-
persistent_import: true,
|
|
500
|
-
already_imported: receipt.already_imported,
|
|
501
|
-
effective_event_delta: receipt.effective_event_delta,
|
|
502
|
-
mapped_track_refs: bundle.track_refs.filter(
|
|
503
|
-
(track) => track.identity_status === "resolved",
|
|
504
|
-
).length,
|
|
505
|
-
events_with_played_duration: bundle.listening_events.filter(
|
|
506
|
-
(event) => Number.isSafeInteger(event.played_ms),
|
|
507
|
-
).length,
|
|
508
|
-
},
|
|
509
|
-
receipt,
|
|
510
|
-
};
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
async function trustedLocalSubjectId(store) {
|
|
514
|
-
const [sourceStatusModule, projectionModule] = await Promise.all([
|
|
515
|
-
import("../../core/apple-library-source-status.mjs"),
|
|
516
|
-
import("../../core/apple-projection-domain-services.mjs"),
|
|
517
|
-
]);
|
|
518
|
-
const { readAppleMusicSourceStatus } = sourceStatusModule;
|
|
519
|
-
const { resolveAppleMusicSubjectId } = projectionModule;
|
|
520
|
-
const source = await readAppleMusicSourceStatus();
|
|
521
|
-
if (source.state === "invalid") {
|
|
522
|
-
const error = new Error("The canonical Apple Music import source is invalid.");
|
|
523
|
-
error.code = "projection_source_invalid";
|
|
524
|
-
throw error;
|
|
525
|
-
}
|
|
526
|
-
const appleSubjectId = source.state === "missing"
|
|
527
|
-
? null
|
|
528
|
-
: await resolveAppleMusicSubjectId();
|
|
529
|
-
return store.localSubjectId({
|
|
530
|
-
...(appleSubjectId ? { preferredSubjectId: appleSubjectId } : {}),
|
|
531
|
-
create: true,
|
|
532
|
-
});
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
function safeCoverage(profile) {
|
|
536
|
-
const coverage = profile?.coverage ?? {};
|
|
537
|
-
return {
|
|
538
|
-
effective_listening_events: Number.isInteger(
|
|
539
|
-
coverage.effective_listening_events,
|
|
540
|
-
)
|
|
541
|
-
? coverage.effective_listening_events
|
|
542
|
-
: 0,
|
|
543
|
-
listening_hours: Number.isFinite(coverage.listening_hours)
|
|
544
|
-
? coverage.listening_hours
|
|
545
|
-
: 0,
|
|
546
|
-
distinct_tracks: Number.isInteger(coverage.listening_tracks)
|
|
547
|
-
? coverage.listening_tracks
|
|
548
|
-
: 0,
|
|
549
|
-
cross_format_track_links: Number.isInteger(
|
|
550
|
-
coverage.cross_format_track_links,
|
|
551
|
-
)
|
|
552
|
-
? coverage.cross_format_track_links
|
|
553
|
-
: 0,
|
|
554
|
-
cross_format_linked_events: Number.isInteger(
|
|
555
|
-
coverage.cross_format_linked_events,
|
|
556
|
-
)
|
|
557
|
-
? coverage.cross_format_linked_events
|
|
558
|
-
: 0,
|
|
559
|
-
cross_format_ambiguous_tracks: Number.isInteger(
|
|
560
|
-
coverage.cross_format_ambiguous_tracks,
|
|
561
|
-
)
|
|
562
|
-
? coverage.cross_format_ambiguous_tracks
|
|
563
|
-
: 0,
|
|
564
|
-
cross_format_ambiguous_events: Number.isInteger(
|
|
565
|
-
coverage.cross_format_ambiguous_events,
|
|
566
|
-
)
|
|
567
|
-
? coverage.cross_format_ambiguous_events
|
|
568
|
-
: 0,
|
|
569
|
-
};
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
function boundedStudioText(value, maximumLength = 160) {
|
|
573
|
-
if (typeof value !== "string") return null;
|
|
574
|
-
const cleaned = value.normalize("NFKC").replace(/\s+/gu, " ").trim();
|
|
575
|
-
if (!cleaned) return null;
|
|
576
|
-
return cleaned.slice(0, maximumLength);
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
function safeTimeMachine(profile) {
|
|
580
|
-
const behavior = profile?.listening_behavior ?? {};
|
|
581
|
-
const candidates = Array.isArray(behavior.time_capsule_tracks)
|
|
582
|
-
? behavior.time_capsule_tracks
|
|
583
|
-
: [];
|
|
584
|
-
const landmarks = candidates
|
|
585
|
-
.map((candidate) => {
|
|
586
|
-
const year = candidate?.capsule_year;
|
|
587
|
-
const title = boundedStudioText(candidate?.label);
|
|
588
|
-
const artist = boundedStudioText(candidate?.artist_credit);
|
|
589
|
-
if (
|
|
590
|
-
!Number.isInteger(year) ||
|
|
591
|
-
year < 1900 ||
|
|
592
|
-
year > 2100 ||
|
|
593
|
-
!title ||
|
|
594
|
-
!artist
|
|
595
|
-
) {
|
|
596
|
-
return null;
|
|
597
|
-
}
|
|
598
|
-
return { year, title, artist };
|
|
599
|
-
})
|
|
600
|
-
.filter(Boolean)
|
|
601
|
-
.sort((left, right) => left.year - right.year)
|
|
602
|
-
.slice(0, 12);
|
|
603
|
-
const retainedYears = [
|
|
604
|
-
...new Set(
|
|
605
|
-
(Array.isArray(behavior.history_arc) ? behavior.history_arc : [])
|
|
606
|
-
.map((item) => item?.year)
|
|
607
|
-
.filter(
|
|
608
|
-
(year) =>
|
|
609
|
-
Number.isInteger(year) && year >= 1900 && year <= 2100,
|
|
610
|
-
),
|
|
611
|
-
),
|
|
612
|
-
].sort((left, right) => left - right);
|
|
613
|
-
const landmarkYears = new Set(landmarks.map((landmark) => landmark.year));
|
|
614
|
-
const representedYears = retainedYears.filter((year) =>
|
|
615
|
-
landmarkYears.has(year),
|
|
616
|
-
);
|
|
617
|
-
const context = behavior.context ?? {};
|
|
618
|
-
const minimumEngagedPlays = Number.isInteger(
|
|
619
|
-
context.time_capsule_minimum_engaged_plays,
|
|
620
|
-
) && context.time_capsule_minimum_engaged_plays >= 1
|
|
621
|
-
? context.time_capsule_minimum_engaged_plays
|
|
622
|
-
: null;
|
|
623
|
-
const minimumListeningMinutes = Number.isFinite(
|
|
624
|
-
context.time_capsule_minimum_listening_minutes,
|
|
625
|
-
) && context.time_capsule_minimum_listening_minutes >= 1
|
|
626
|
-
? context.time_capsule_minimum_listening_minutes
|
|
627
|
-
: null;
|
|
628
|
-
return {
|
|
629
|
-
ready: landmarks.length >= 2,
|
|
630
|
-
landmark_count: landmarks.length,
|
|
631
|
-
retained_year_count: retainedYears.length,
|
|
632
|
-
represented_year_count: representedYears.length,
|
|
633
|
-
unrepresented_years: retainedYears.filter(
|
|
634
|
-
(year) => !landmarkYears.has(year),
|
|
635
|
-
),
|
|
636
|
-
minimum_engaged_plays: minimumEngagedPlays,
|
|
637
|
-
minimum_listening_minutes: minimumListeningMinutes,
|
|
638
|
-
landmarks,
|
|
639
|
-
};
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
function studioMonthIndex(monthKey) {
|
|
643
|
-
if (!/^\d{4}-(?:0[1-9]|1[0-2])$/u.test(monthKey)) return null;
|
|
644
|
-
const year = Number(monthKey.slice(0, 4));
|
|
645
|
-
const month = Number(monthKey.slice(5, 7));
|
|
646
|
-
if (!Number.isInteger(year) || year < 1900 || year > 9999) return null;
|
|
647
|
-
return year * 12 + month - 1;
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
function emptyMonthlyActivity() {
|
|
651
|
-
return {
|
|
652
|
-
ready: false,
|
|
653
|
-
timezone: "UTC",
|
|
654
|
-
retained_span_months: 0,
|
|
655
|
-
represented_month_count: 0,
|
|
656
|
-
active_month_count: 0,
|
|
657
|
-
omitted_earlier_month_count: 0,
|
|
658
|
-
preview_month_count: 0,
|
|
659
|
-
preview_active_month_count: 0,
|
|
660
|
-
preview_omitted_month_count: 0,
|
|
661
|
-
peak_listening_minutes: 0,
|
|
662
|
-
months: [],
|
|
663
|
-
};
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
function safeMonthlyActivity(profile) {
|
|
667
|
-
const value = profile?.listening_behavior?.monthly_activity;
|
|
668
|
-
if (!value || typeof value !== "object" || value.timezone !== "UTC") {
|
|
669
|
-
return emptyMonthlyActivity();
|
|
670
|
-
}
|
|
671
|
-
const firstMonth = boundedStudioText(value.first_month, 7);
|
|
672
|
-
const lastMonth = boundedStudioText(value.last_month, 7);
|
|
673
|
-
const firstIndex = studioMonthIndex(firstMonth);
|
|
674
|
-
const lastIndex = studioMonthIndex(lastMonth);
|
|
675
|
-
const retainedSpanMonths = value.retained_span_months;
|
|
676
|
-
const representedMonthCount = value.represented_month_count;
|
|
677
|
-
const activeMonthCount = value.active_month_count;
|
|
678
|
-
const omittedEarlierMonthCount = value.omitted_earlier_month_count;
|
|
679
|
-
const peakListeningMinutes = value.peak_listening_minutes;
|
|
680
|
-
const rawMonths = Array.isArray(value.months) ? value.months : [];
|
|
681
|
-
const months = rawMonths
|
|
682
|
-
.map((item) => {
|
|
683
|
-
const month = boundedStudioText(item?.month, 7);
|
|
684
|
-
const eventCount = item?.event_count;
|
|
685
|
-
const engagedPlayCount = item?.engaged_play_count;
|
|
686
|
-
const listeningMinutes = item?.listening_minutes;
|
|
687
|
-
const distinctTracks = item?.distinct_tracks;
|
|
688
|
-
if (
|
|
689
|
-
studioMonthIndex(month) === null ||
|
|
690
|
-
!Number.isInteger(eventCount) ||
|
|
691
|
-
eventCount < 0 ||
|
|
692
|
-
!Number.isInteger(engagedPlayCount) ||
|
|
693
|
-
engagedPlayCount < 0 ||
|
|
694
|
-
engagedPlayCount > eventCount ||
|
|
695
|
-
!Number.isFinite(listeningMinutes) ||
|
|
696
|
-
listeningMinutes < 0 ||
|
|
697
|
-
!Number.isInteger(distinctTracks) ||
|
|
698
|
-
distinctTracks < 0
|
|
699
|
-
) {
|
|
700
|
-
return null;
|
|
701
|
-
}
|
|
702
|
-
return {
|
|
703
|
-
month,
|
|
704
|
-
event_count: eventCount,
|
|
705
|
-
engaged_play_count: engagedPlayCount,
|
|
706
|
-
listening_minutes: listeningMinutes,
|
|
707
|
-
distinct_tracks: distinctTracks,
|
|
708
|
-
};
|
|
709
|
-
})
|
|
710
|
-
.filter(Boolean)
|
|
711
|
-
.sort((left, right) =>
|
|
712
|
-
left.month < right.month ? -1 : left.month > right.month ? 1 : 0,
|
|
713
|
-
);
|
|
714
|
-
const monthIndexes = months.map((month) => studioMonthIndex(month.month));
|
|
715
|
-
if (
|
|
716
|
-
firstIndex === null ||
|
|
717
|
-
lastIndex === null ||
|
|
718
|
-
firstIndex > lastIndex ||
|
|
719
|
-
!Number.isInteger(retainedSpanMonths) ||
|
|
720
|
-
retainedSpanMonths < 1 ||
|
|
721
|
-
!Number.isInteger(representedMonthCount) ||
|
|
722
|
-
representedMonthCount < 1 ||
|
|
723
|
-
representedMonthCount > 240 ||
|
|
724
|
-
!Number.isInteger(activeMonthCount) ||
|
|
725
|
-
activeMonthCount < 1 ||
|
|
726
|
-
!Number.isInteger(omittedEarlierMonthCount) ||
|
|
727
|
-
omittedEarlierMonthCount < 0 ||
|
|
728
|
-
!Number.isFinite(peakListeningMinutes) ||
|
|
729
|
-
peakListeningMinutes < 0 ||
|
|
730
|
-
months.length !== rawMonths.length ||
|
|
731
|
-
months.length !== representedMonthCount ||
|
|
732
|
-
months[0]?.month !== firstMonth ||
|
|
733
|
-
months.at(-1)?.month !== lastMonth ||
|
|
734
|
-
retainedSpanMonths !== representedMonthCount + omittedEarlierMonthCount ||
|
|
735
|
-
activeMonthCount !== months.filter((month) => month.event_count > 0).length ||
|
|
736
|
-
peakListeningMinutes !== Math.max(...months.map((month) => month.listening_minutes)) ||
|
|
737
|
-
monthIndexes.some(
|
|
738
|
-
(index, position) =>
|
|
739
|
-
index === null ||
|
|
740
|
-
(position > 0 && index !== monthIndexes[position - 1] + 1),
|
|
741
|
-
)
|
|
742
|
-
) {
|
|
743
|
-
return emptyMonthlyActivity();
|
|
744
|
-
}
|
|
745
|
-
const previewMonths = months.slice(-72);
|
|
746
|
-
return {
|
|
747
|
-
ready: true,
|
|
748
|
-
timezone: "UTC",
|
|
749
|
-
first_month: firstMonth,
|
|
750
|
-
last_month: lastMonth,
|
|
751
|
-
preview_first_month: previewMonths[0].month,
|
|
752
|
-
retained_span_months: retainedSpanMonths,
|
|
753
|
-
represented_month_count: representedMonthCount,
|
|
754
|
-
active_month_count: activeMonthCount,
|
|
755
|
-
omitted_earlier_month_count: omittedEarlierMonthCount,
|
|
756
|
-
preview_month_count: previewMonths.length,
|
|
757
|
-
preview_active_month_count: previewMonths.filter(
|
|
758
|
-
(month) => month.event_count > 0,
|
|
759
|
-
).length,
|
|
760
|
-
preview_omitted_month_count: months.length - previewMonths.length,
|
|
761
|
-
peak_listening_minutes: Math.max(
|
|
762
|
-
...previewMonths.map((month) => month.listening_minutes),
|
|
763
|
-
),
|
|
764
|
-
months: previewMonths,
|
|
765
|
-
};
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
function studioSeasonIndex(value) {
|
|
769
|
-
if (!/^\d{4}-Q[1-4]$/u.test(value)) return null;
|
|
770
|
-
const year = Number(value.slice(0, 4));
|
|
771
|
-
const quarter = Number(value.slice(6));
|
|
772
|
-
if (!Number.isInteger(year) || year < 1900 || year > 9999) return null;
|
|
773
|
-
return year * 4 + quarter - 1;
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
function emptyListeningSeasons() {
|
|
777
|
-
return {
|
|
778
|
-
ready: false,
|
|
779
|
-
timezone: "UTC",
|
|
780
|
-
retained_season_count: 0,
|
|
781
|
-
represented_season_count: 0,
|
|
782
|
-
active_season_count: 0,
|
|
783
|
-
represented_active_season_count: 0,
|
|
784
|
-
omitted_earlier_season_count: 0,
|
|
785
|
-
omitted_earlier_active_season_count: 0,
|
|
786
|
-
preview_season_count: 0,
|
|
787
|
-
preview_active_season_count: 0,
|
|
788
|
-
preview_omitted_season_count: 0,
|
|
789
|
-
preview_omitted_active_season_count: 0,
|
|
790
|
-
seasons: [],
|
|
791
|
-
};
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
function safeListeningSeasons(profile) {
|
|
795
|
-
const value = profile?.listening_behavior?.listening_seasons;
|
|
796
|
-
if (
|
|
797
|
-
!value ||
|
|
798
|
-
typeof value !== "object" ||
|
|
799
|
-
value.timezone !== "UTC" ||
|
|
800
|
-
value.alignment !== "calendar_quarter" ||
|
|
801
|
-
value.season_length_months !== 3
|
|
802
|
-
) {
|
|
803
|
-
return emptyListeningSeasons();
|
|
804
|
-
}
|
|
805
|
-
const retainedFirstSeason = boundedStudioText(
|
|
806
|
-
value.retained_first_season,
|
|
807
|
-
7,
|
|
808
|
-
);
|
|
809
|
-
const representedFirstSeason = boundedStudioText(
|
|
810
|
-
value.represented_first_season,
|
|
811
|
-
7,
|
|
812
|
-
);
|
|
813
|
-
const lastSeason = boundedStudioText(value.last_season, 7);
|
|
814
|
-
const retainedFirstIndex = studioSeasonIndex(retainedFirstSeason);
|
|
815
|
-
const representedFirstIndex = studioSeasonIndex(representedFirstSeason);
|
|
816
|
-
const lastIndex = studioSeasonIndex(lastSeason);
|
|
817
|
-
const retainedSeasonCount = value.retained_season_count;
|
|
818
|
-
const representedSeasonCount = value.represented_season_count;
|
|
819
|
-
const activeSeasonCount = value.active_season_count;
|
|
820
|
-
const representedActiveSeasonCount = value.represented_active_season_count;
|
|
821
|
-
const omittedEarlierSeasonCount = value.omitted_earlier_season_count;
|
|
822
|
-
const omittedEarlierActiveSeasonCount =
|
|
823
|
-
value.omitted_earlier_active_season_count;
|
|
824
|
-
const rawSeasons = Array.isArray(value.seasons) ? value.seasons : [];
|
|
825
|
-
const seasons = rawSeasons
|
|
826
|
-
.map((item) => {
|
|
827
|
-
const key = boundedStudioText(item?.key, 7);
|
|
828
|
-
const index = studioSeasonIndex(key);
|
|
829
|
-
const startMonth = boundedStudioText(item?.start_month, 7);
|
|
830
|
-
const endMonth = boundedStudioText(item?.end_month, 7);
|
|
831
|
-
const startMonthIndex = studioMonthIndex(startMonth);
|
|
832
|
-
const endMonthIndex = studioMonthIndex(endMonth);
|
|
833
|
-
const retainedMonthCount = item?.retained_month_count;
|
|
834
|
-
const activeMonthCount = item?.active_month_count;
|
|
835
|
-
const eventCount = item?.event_count;
|
|
836
|
-
const engagedPlayCount = item?.engaged_play_count;
|
|
837
|
-
const listeningMinutes = item?.listening_minutes;
|
|
838
|
-
const distinctTracks = item?.distinct_tracks;
|
|
839
|
-
const firstObservedTracks = item?.first_observed_tracks;
|
|
840
|
-
const returningTracks = item?.returning_tracks;
|
|
841
|
-
const leadingArtist = boundedStudioText(item?.leading_artist?.name);
|
|
842
|
-
const leadingEventCount = item?.leading_artist?.event_count;
|
|
843
|
-
const leadingEngagedPlayCount =
|
|
844
|
-
item?.leading_artist?.engaged_play_count;
|
|
845
|
-
const leadingListeningMinutes =
|
|
846
|
-
item?.leading_artist?.listening_minutes;
|
|
847
|
-
const leadingDistinctTracks = item?.leading_artist?.distinct_tracks;
|
|
848
|
-
const signatureTitle = boundedStudioText(item?.signature_track?.label);
|
|
849
|
-
const signatureArtist = boundedStudioText(
|
|
850
|
-
item?.signature_track?.artist_credit,
|
|
851
|
-
);
|
|
852
|
-
const signaturePlayCount = item?.signature_track?.play_count;
|
|
853
|
-
const signatureEngagedPlayCount =
|
|
854
|
-
item?.signature_track?.engaged_play_count;
|
|
855
|
-
const signatureListeningMinutes =
|
|
856
|
-
item?.signature_track?.listening_minutes;
|
|
857
|
-
const signatureExplicitSkips = item?.signature_track?.explicit_skips;
|
|
858
|
-
const active = Number.isInteger(eventCount) && eventCount > 0;
|
|
859
|
-
if (
|
|
860
|
-
index === null ||
|
|
861
|
-
startMonthIndex === null ||
|
|
862
|
-
endMonthIndex === null ||
|
|
863
|
-
startMonthIndex > endMonthIndex ||
|
|
864
|
-
Math.floor(startMonthIndex / 3) !== index ||
|
|
865
|
-
Math.floor(endMonthIndex / 3) !== index ||
|
|
866
|
-
!Number.isInteger(retainedMonthCount) ||
|
|
867
|
-
retainedMonthCount < 1 ||
|
|
868
|
-
retainedMonthCount > 3 ||
|
|
869
|
-
retainedMonthCount !== endMonthIndex - startMonthIndex + 1 ||
|
|
870
|
-
!Number.isInteger(activeMonthCount) ||
|
|
871
|
-
activeMonthCount < 0 ||
|
|
872
|
-
activeMonthCount > retainedMonthCount ||
|
|
873
|
-
!Number.isInteger(eventCount) ||
|
|
874
|
-
eventCount < 0 ||
|
|
875
|
-
!Number.isInteger(engagedPlayCount) ||
|
|
876
|
-
engagedPlayCount < 0 ||
|
|
877
|
-
engagedPlayCount > eventCount ||
|
|
878
|
-
!Number.isFinite(listeningMinutes) ||
|
|
879
|
-
listeningMinutes < 0 ||
|
|
880
|
-
!Number.isInteger(distinctTracks) ||
|
|
881
|
-
distinctTracks < 0 ||
|
|
882
|
-
distinctTracks > eventCount ||
|
|
883
|
-
!Number.isInteger(firstObservedTracks) ||
|
|
884
|
-
firstObservedTracks < 0 ||
|
|
885
|
-
!Number.isInteger(returningTracks) ||
|
|
886
|
-
returningTracks < 0 ||
|
|
887
|
-
firstObservedTracks + returningTracks !== distinctTracks ||
|
|
888
|
-
(!active &&
|
|
889
|
-
(activeMonthCount !== 0 ||
|
|
890
|
-
engagedPlayCount !== 0 ||
|
|
891
|
-
listeningMinutes !== 0 ||
|
|
892
|
-
distinctTracks !== 0 ||
|
|
893
|
-
leadingArtist !== null ||
|
|
894
|
-
signatureTitle !== null)) ||
|
|
895
|
-
(active &&
|
|
896
|
-
(activeMonthCount < 1 ||
|
|
897
|
-
!leadingArtist ||
|
|
898
|
-
!signatureTitle ||
|
|
899
|
-
!signatureArtist ||
|
|
900
|
-
!Number.isInteger(leadingEventCount) ||
|
|
901
|
-
leadingEventCount < 1 ||
|
|
902
|
-
leadingEventCount > eventCount ||
|
|
903
|
-
!Number.isInteger(leadingEngagedPlayCount) ||
|
|
904
|
-
leadingEngagedPlayCount < 0 ||
|
|
905
|
-
leadingEngagedPlayCount > leadingEventCount ||
|
|
906
|
-
leadingEngagedPlayCount > engagedPlayCount ||
|
|
907
|
-
!Number.isFinite(leadingListeningMinutes) ||
|
|
908
|
-
leadingListeningMinutes < 0 ||
|
|
909
|
-
leadingListeningMinutes > listeningMinutes ||
|
|
910
|
-
!Number.isInteger(leadingDistinctTracks) ||
|
|
911
|
-
leadingDistinctTracks < 1 ||
|
|
912
|
-
leadingDistinctTracks > distinctTracks ||
|
|
913
|
-
!Number.isInteger(signaturePlayCount) ||
|
|
914
|
-
signaturePlayCount < 1 ||
|
|
915
|
-
signaturePlayCount > eventCount ||
|
|
916
|
-
!Number.isInteger(signatureEngagedPlayCount) ||
|
|
917
|
-
signatureEngagedPlayCount < 0 ||
|
|
918
|
-
!Number.isInteger(signatureExplicitSkips) ||
|
|
919
|
-
signatureExplicitSkips < 0 ||
|
|
920
|
-
signatureEngagedPlayCount + signatureExplicitSkips !==
|
|
921
|
-
signaturePlayCount ||
|
|
922
|
-
signatureEngagedPlayCount > engagedPlayCount ||
|
|
923
|
-
!Number.isFinite(signatureListeningMinutes) ||
|
|
924
|
-
signatureListeningMinutes < 0 ||
|
|
925
|
-
signatureListeningMinutes > listeningMinutes))
|
|
926
|
-
) {
|
|
927
|
-
return null;
|
|
928
|
-
}
|
|
929
|
-
return {
|
|
930
|
-
key,
|
|
931
|
-
start_month: startMonth,
|
|
932
|
-
end_month: endMonth,
|
|
933
|
-
retained_month_count: retainedMonthCount,
|
|
934
|
-
active_month_count: activeMonthCount,
|
|
935
|
-
event_count: eventCount,
|
|
936
|
-
listening_minutes: listeningMinutes,
|
|
937
|
-
distinct_tracks: distinctTracks,
|
|
938
|
-
first_observed_tracks: firstObservedTracks,
|
|
939
|
-
returning_tracks: returningTracks,
|
|
940
|
-
...(active
|
|
941
|
-
? {
|
|
942
|
-
leading_artist: leadingArtist,
|
|
943
|
-
signature_track: {
|
|
944
|
-
title: signatureTitle,
|
|
945
|
-
artist: signatureArtist,
|
|
946
|
-
},
|
|
947
|
-
}
|
|
948
|
-
: {}),
|
|
949
|
-
};
|
|
950
|
-
})
|
|
951
|
-
.filter(Boolean);
|
|
952
|
-
const indexes = seasons.map((season) => studioSeasonIndex(season.key));
|
|
953
|
-
if (
|
|
954
|
-
retainedFirstIndex === null ||
|
|
955
|
-
representedFirstIndex === null ||
|
|
956
|
-
lastIndex === null ||
|
|
957
|
-
retainedFirstIndex > representedFirstIndex ||
|
|
958
|
-
representedFirstIndex > lastIndex ||
|
|
959
|
-
!Number.isInteger(retainedSeasonCount) ||
|
|
960
|
-
retainedSeasonCount < 1 ||
|
|
961
|
-
!Number.isInteger(representedSeasonCount) ||
|
|
962
|
-
representedSeasonCount < 1 ||
|
|
963
|
-
representedSeasonCount > 80 ||
|
|
964
|
-
!Number.isInteger(activeSeasonCount) ||
|
|
965
|
-
activeSeasonCount < 1 ||
|
|
966
|
-
!Number.isInteger(representedActiveSeasonCount) ||
|
|
967
|
-
representedActiveSeasonCount < 1 ||
|
|
968
|
-
!Number.isInteger(omittedEarlierSeasonCount) ||
|
|
969
|
-
omittedEarlierSeasonCount < 0 ||
|
|
970
|
-
!Number.isInteger(omittedEarlierActiveSeasonCount) ||
|
|
971
|
-
omittedEarlierActiveSeasonCount < 0 ||
|
|
972
|
-
seasons.length !== rawSeasons.length ||
|
|
973
|
-
seasons.length !== representedSeasonCount ||
|
|
974
|
-
indexes[0] !== representedFirstIndex ||
|
|
975
|
-
indexes.at(-1) !== lastIndex ||
|
|
976
|
-
indexes.some(
|
|
977
|
-
(index, position) =>
|
|
978
|
-
index === null ||
|
|
979
|
-
(position > 0 && index !== indexes[position - 1] + 1),
|
|
980
|
-
) ||
|
|
981
|
-
retainedSeasonCount !== lastIndex - retainedFirstIndex + 1 ||
|
|
982
|
-
representedSeasonCount !== lastIndex - representedFirstIndex + 1 ||
|
|
983
|
-
omittedEarlierSeasonCount !== representedFirstIndex - retainedFirstIndex ||
|
|
984
|
-
activeSeasonCount !==
|
|
985
|
-
representedActiveSeasonCount + omittedEarlierActiveSeasonCount ||
|
|
986
|
-
representedActiveSeasonCount !==
|
|
987
|
-
seasons.filter((season) => season.event_count > 0).length ||
|
|
988
|
-
activeSeasonCount > retainedSeasonCount ||
|
|
989
|
-
representedActiveSeasonCount > representedSeasonCount ||
|
|
990
|
-
omittedEarlierActiveSeasonCount > omittedEarlierSeasonCount
|
|
991
|
-
) {
|
|
992
|
-
return emptyListeningSeasons();
|
|
993
|
-
}
|
|
994
|
-
const previewSeasons = seasons.slice(-6);
|
|
995
|
-
const previewActiveSeasonCount = previewSeasons.filter(
|
|
996
|
-
(season) => season.event_count > 0,
|
|
997
|
-
).length;
|
|
998
|
-
return {
|
|
999
|
-
ready: previewActiveSeasonCount > 0,
|
|
1000
|
-
timezone: "UTC",
|
|
1001
|
-
retained_first_season: retainedFirstSeason,
|
|
1002
|
-
represented_first_season: representedFirstSeason,
|
|
1003
|
-
last_season: lastSeason,
|
|
1004
|
-
retained_season_count: retainedSeasonCount,
|
|
1005
|
-
represented_season_count: representedSeasonCount,
|
|
1006
|
-
active_season_count: activeSeasonCount,
|
|
1007
|
-
represented_active_season_count: representedActiveSeasonCount,
|
|
1008
|
-
omitted_earlier_season_count: omittedEarlierSeasonCount,
|
|
1009
|
-
omitted_earlier_active_season_count: omittedEarlierActiveSeasonCount,
|
|
1010
|
-
preview_season_count: previewSeasons.length,
|
|
1011
|
-
preview_active_season_count: previewActiveSeasonCount,
|
|
1012
|
-
preview_omitted_season_count: seasons.length - previewSeasons.length,
|
|
1013
|
-
preview_omitted_active_season_count:
|
|
1014
|
-
representedActiveSeasonCount - previewActiveSeasonCount,
|
|
1015
|
-
seasons: previewSeasons,
|
|
1016
|
-
};
|
|
1017
|
-
}
|
|
1018
|
-
|
|
1019
|
-
function safeHistoricalReturns(profile) {
|
|
1020
|
-
const behavior = profile?.listening_behavior ?? {};
|
|
1021
|
-
const context = behavior.context ?? {};
|
|
1022
|
-
const minimumGapDays = Number.isInteger(
|
|
1023
|
-
context.historical_return_minimum_gap_days,
|
|
1024
|
-
) && context.historical_return_minimum_gap_days >= 1
|
|
1025
|
-
? context.historical_return_minimum_gap_days
|
|
1026
|
-
: null;
|
|
1027
|
-
const tracks = (Array.isArray(behavior.historical_return_tracks)
|
|
1028
|
-
? behavior.historical_return_tracks
|
|
1029
|
-
: [])
|
|
1030
|
-
.map((candidate) => {
|
|
1031
|
-
const title = boundedStudioText(candidate?.label);
|
|
1032
|
-
const artist = boundedStudioText(candidate?.artist_credit);
|
|
1033
|
-
const returnCount = candidate?.return_count;
|
|
1034
|
-
const longestGapDays = candidate?.longest_gap_days;
|
|
1035
|
-
const latestReturnGapDays = candidate?.latest_return_gap_days;
|
|
1036
|
-
const latestReturnAt = candidate?.latest_return_at;
|
|
1037
|
-
if (
|
|
1038
|
-
!title ||
|
|
1039
|
-
!artist ||
|
|
1040
|
-
!Number.isInteger(returnCount) ||
|
|
1041
|
-
returnCount < 1 ||
|
|
1042
|
-
!Number.isInteger(longestGapDays) ||
|
|
1043
|
-
longestGapDays < 1 ||
|
|
1044
|
-
!Number.isInteger(latestReturnGapDays) ||
|
|
1045
|
-
latestReturnGapDays < 1 ||
|
|
1046
|
-
latestReturnGapDays > longestGapDays ||
|
|
1047
|
-
typeof latestReturnAt !== "string" ||
|
|
1048
|
-
!Number.isFinite(Date.parse(latestReturnAt))
|
|
1049
|
-
) {
|
|
1050
|
-
return null;
|
|
1051
|
-
}
|
|
1052
|
-
return {
|
|
1053
|
-
title,
|
|
1054
|
-
artist,
|
|
1055
|
-
return_count: returnCount,
|
|
1056
|
-
longest_gap_days: longestGapDays,
|
|
1057
|
-
latest_return_gap_days: latestReturnGapDays,
|
|
1058
|
-
latest_return_date: new Date(latestReturnAt)
|
|
1059
|
-
.toISOString()
|
|
1060
|
-
.slice(0, 10),
|
|
1061
|
-
};
|
|
1062
|
-
})
|
|
1063
|
-
.filter(Boolean)
|
|
1064
|
-
.slice(0, 4);
|
|
1065
|
-
return {
|
|
1066
|
-
ready: tracks.length > 0,
|
|
1067
|
-
return_track_count: tracks.length,
|
|
1068
|
-
minimum_gap_days: minimumGapDays,
|
|
1069
|
-
tracks,
|
|
1070
|
-
};
|
|
1071
|
-
}
|
|
1072
|
-
|
|
1073
|
-
function safeBackToBack(profile) {
|
|
1074
|
-
const behavior = profile?.listening_behavior ?? {};
|
|
1075
|
-
const context = behavior.context ?? {};
|
|
1076
|
-
const minimumConsecutivePlays = Number.isInteger(
|
|
1077
|
-
context.back_to_back_minimum_consecutive_plays,
|
|
1078
|
-
) && context.back_to_back_minimum_consecutive_plays >= 2
|
|
1079
|
-
? context.back_to_back_minimum_consecutive_plays
|
|
1080
|
-
: null;
|
|
1081
|
-
const minimumPlayedSeconds = Number.isInteger(
|
|
1082
|
-
context.back_to_back_minimum_played_seconds,
|
|
1083
|
-
) && context.back_to_back_minimum_played_seconds >= 1
|
|
1084
|
-
? context.back_to_back_minimum_played_seconds
|
|
1085
|
-
: null;
|
|
1086
|
-
const maximumGapMinutes = Number.isInteger(
|
|
1087
|
-
context.back_to_back_maximum_gap_minutes,
|
|
1088
|
-
) && context.back_to_back_maximum_gap_minutes >= 1
|
|
1089
|
-
? context.back_to_back_maximum_gap_minutes
|
|
1090
|
-
: null;
|
|
1091
|
-
const tracks = (Array.isArray(behavior.back_to_back_tracks)
|
|
1092
|
-
? behavior.back_to_back_tracks
|
|
1093
|
-
: [])
|
|
1094
|
-
.map((candidate) => {
|
|
1095
|
-
const title = boundedStudioText(candidate?.label);
|
|
1096
|
-
const artist = boundedStudioText(candidate?.artist_credit);
|
|
1097
|
-
const burstCount = candidate?.burst_count;
|
|
1098
|
-
const maximumConsecutive = candidate?.maximum_consecutive_plays;
|
|
1099
|
-
const playsInBursts = candidate?.plays_in_bursts;
|
|
1100
|
-
const listeningMinutes = candidate?.listening_minutes_in_bursts;
|
|
1101
|
-
const latestBurstAt = candidate?.latest_burst_at;
|
|
1102
|
-
if (
|
|
1103
|
-
!title ||
|
|
1104
|
-
!artist ||
|
|
1105
|
-
!Number.isInteger(burstCount) ||
|
|
1106
|
-
burstCount < 1 ||
|
|
1107
|
-
!Number.isInteger(maximumConsecutive) ||
|
|
1108
|
-
maximumConsecutive < 2 ||
|
|
1109
|
-
!Number.isInteger(playsInBursts) ||
|
|
1110
|
-
playsInBursts < maximumConsecutive ||
|
|
1111
|
-
playsInBursts < burstCount * 2 ||
|
|
1112
|
-
!Number.isFinite(listeningMinutes) ||
|
|
1113
|
-
listeningMinutes < 0 ||
|
|
1114
|
-
typeof latestBurstAt !== "string" ||
|
|
1115
|
-
!Number.isFinite(Date.parse(latestBurstAt)) ||
|
|
1116
|
-
candidate?.sequence_signal !== "adjacent retained plays"
|
|
1117
|
-
) {
|
|
1118
|
-
return null;
|
|
1119
|
-
}
|
|
1120
|
-
return {
|
|
1121
|
-
title,
|
|
1122
|
-
artist,
|
|
1123
|
-
burst_count: burstCount,
|
|
1124
|
-
maximum_consecutive_plays: maximumConsecutive,
|
|
1125
|
-
plays_in_bursts: playsInBursts,
|
|
1126
|
-
listening_minutes_in_bursts: listeningMinutes,
|
|
1127
|
-
latest_burst_date: new Date(latestBurstAt)
|
|
1128
|
-
.toISOString()
|
|
1129
|
-
.slice(0, 10),
|
|
1130
|
-
};
|
|
1131
|
-
})
|
|
1132
|
-
.filter(Boolean)
|
|
1133
|
-
.slice(0, 4);
|
|
1134
|
-
return {
|
|
1135
|
-
ready: tracks.length > 0,
|
|
1136
|
-
track_count: tracks.length,
|
|
1137
|
-
minimum_consecutive_plays: minimumConsecutivePlays,
|
|
1138
|
-
minimum_played_seconds: minimumPlayedSeconds,
|
|
1139
|
-
maximum_gap_minutes: maximumGapMinutes,
|
|
1140
|
-
tracks,
|
|
1141
|
-
};
|
|
1142
|
-
}
|
|
1143
|
-
|
|
1144
|
-
function safeContinuity(profile) {
|
|
1145
|
-
const behavior = profile?.listening_behavior ?? {};
|
|
1146
|
-
const relationships = (Array.isArray(behavior.artist_relationships)
|
|
1147
|
-
? behavior.artist_relationships
|
|
1148
|
-
: [])
|
|
1149
|
-
.map((item) => {
|
|
1150
|
-
const name = boundedStudioText(item?.name);
|
|
1151
|
-
const firstYear = item?.first_year;
|
|
1152
|
-
const lastYear = item?.last_year;
|
|
1153
|
-
const activeYears = item?.active_years;
|
|
1154
|
-
const spanYears = item?.span_years;
|
|
1155
|
-
if (
|
|
1156
|
-
!name ||
|
|
1157
|
-
!Number.isInteger(firstYear) ||
|
|
1158
|
-
!Number.isInteger(lastYear) ||
|
|
1159
|
-
firstYear < 1900 ||
|
|
1160
|
-
lastYear > 2100 ||
|
|
1161
|
-
firstYear > lastYear ||
|
|
1162
|
-
!Number.isInteger(activeYears) ||
|
|
1163
|
-
activeYears < 2 ||
|
|
1164
|
-
!Number.isInteger(spanYears) ||
|
|
1165
|
-
spanYears < 2
|
|
1166
|
-
) {
|
|
1167
|
-
return null;
|
|
1168
|
-
}
|
|
1169
|
-
return {
|
|
1170
|
-
name,
|
|
1171
|
-
first_year: firstYear,
|
|
1172
|
-
last_year: lastYear,
|
|
1173
|
-
active_years: activeYears,
|
|
1174
|
-
span_years: spanYears,
|
|
1175
|
-
};
|
|
1176
|
-
})
|
|
1177
|
-
.filter(Boolean)
|
|
1178
|
-
.slice(0, 4);
|
|
1179
|
-
const transitions = (Array.isArray(behavior.year_transitions)
|
|
1180
|
-
? behavior.year_transitions
|
|
1181
|
-
: [])
|
|
1182
|
-
.map((item) => {
|
|
1183
|
-
const fromYear = item?.from_year;
|
|
1184
|
-
const toYear = item?.to_year;
|
|
1185
|
-
const retained = item?.retained_artist_count;
|
|
1186
|
-
const introduced = item?.new_artist_count;
|
|
1187
|
-
const continuity = item?.continuity_percent;
|
|
1188
|
-
if (
|
|
1189
|
-
!Number.isInteger(fromYear) ||
|
|
1190
|
-
!Number.isInteger(toYear) ||
|
|
1191
|
-
fromYear < 1900 ||
|
|
1192
|
-
toYear > 2100 ||
|
|
1193
|
-
fromYear >= toYear ||
|
|
1194
|
-
!Number.isInteger(retained) ||
|
|
1195
|
-
retained < 0 ||
|
|
1196
|
-
!Number.isInteger(introduced) ||
|
|
1197
|
-
introduced < 0 ||
|
|
1198
|
-
!Number.isFinite(continuity) ||
|
|
1199
|
-
continuity < 0 ||
|
|
1200
|
-
continuity > 100
|
|
1201
|
-
) {
|
|
1202
|
-
return null;
|
|
1203
|
-
}
|
|
1204
|
-
return {
|
|
1205
|
-
from_year: fromYear,
|
|
1206
|
-
to_year: toYear,
|
|
1207
|
-
retained_artist_count: retained,
|
|
1208
|
-
new_artist_count: introduced,
|
|
1209
|
-
continuity_percent: continuity,
|
|
1210
|
-
};
|
|
1211
|
-
})
|
|
1212
|
-
.filter(Boolean)
|
|
1213
|
-
.sort((left, right) => left.to_year - right.to_year)
|
|
1214
|
-
.slice(-12);
|
|
1215
|
-
return {
|
|
1216
|
-
ready: relationships.length > 0 || transitions.length > 0,
|
|
1217
|
-
relationship_count: relationships.length,
|
|
1218
|
-
transition_count: transitions.length,
|
|
1219
|
-
relationships,
|
|
1220
|
-
latest_transition: transitions.at(-1) ?? null,
|
|
1221
|
-
};
|
|
1222
|
-
}
|
|
1223
|
-
|
|
1224
|
-
function safeListeningPatterns(profile) {
|
|
1225
|
-
const behavior = profile?.listening_behavior ?? {};
|
|
1226
|
-
const releases = (Array.isArray(behavior.release_depth)
|
|
1227
|
-
? behavior.release_depth
|
|
1228
|
-
: [])
|
|
1229
|
-
.map((item) => {
|
|
1230
|
-
const title = boundedStudioText(item?.title);
|
|
1231
|
-
const artist = boundedStudioText(item?.artist_credit);
|
|
1232
|
-
const distinctTracks = item?.distinct_tracks;
|
|
1233
|
-
const activeYears = item?.active_years;
|
|
1234
|
-
if (
|
|
1235
|
-
!title ||
|
|
1236
|
-
!artist ||
|
|
1237
|
-
!Number.isInteger(distinctTracks) ||
|
|
1238
|
-
distinctTracks < 1 ||
|
|
1239
|
-
!Number.isInteger(activeYears) ||
|
|
1240
|
-
activeYears < 1
|
|
1241
|
-
) {
|
|
1242
|
-
return null;
|
|
1243
|
-
}
|
|
1244
|
-
return {
|
|
1245
|
-
title,
|
|
1246
|
-
artist_credit: artist,
|
|
1247
|
-
distinct_tracks: distinctTracks,
|
|
1248
|
-
active_years: activeYears,
|
|
1249
|
-
};
|
|
1250
|
-
})
|
|
1251
|
-
.filter(Boolean)
|
|
1252
|
-
.slice(0, 4);
|
|
1253
|
-
const value = behavior.session_summary;
|
|
1254
|
-
const session = value &&
|
|
1255
|
-
value.source === "spotify_extended_history" &&
|
|
1256
|
-
value.method === "track_stop_gap" &&
|
|
1257
|
-
Number.isInteger(value.gap_minutes) &&
|
|
1258
|
-
value.gap_minutes >= 1 &&
|
|
1259
|
-
Number.isInteger(value.session_count) &&
|
|
1260
|
-
value.session_count >= 1 &&
|
|
1261
|
-
Number.isFinite(value.median_plays) &&
|
|
1262
|
-
value.median_plays >= 0 &&
|
|
1263
|
-
Number.isFinite(value.median_listening_minutes) &&
|
|
1264
|
-
value.median_listening_minutes >= 0 &&
|
|
1265
|
-
Number.isFinite(value.extended_sequence_percent) &&
|
|
1266
|
-
value.extended_sequence_percent >= 0 &&
|
|
1267
|
-
value.extended_sequence_percent <= 100 &&
|
|
1268
|
-
Number.isInteger(value.extended_sequence_minimum_plays) &&
|
|
1269
|
-
value.extended_sequence_minimum_plays >= 2
|
|
1270
|
-
? {
|
|
1271
|
-
gap_minutes: value.gap_minutes,
|
|
1272
|
-
session_count: value.session_count,
|
|
1273
|
-
median_plays: value.median_plays,
|
|
1274
|
-
median_listening_minutes: value.median_listening_minutes,
|
|
1275
|
-
extended_sequence_minimum_plays:
|
|
1276
|
-
value.extended_sequence_minimum_plays,
|
|
1277
|
-
extended_sequence_percent: value.extended_sequence_percent,
|
|
1278
|
-
}
|
|
1279
|
-
: null;
|
|
1280
|
-
return {
|
|
1281
|
-
ready: releases.length > 0 || session !== null,
|
|
1282
|
-
release_count: releases.length,
|
|
1283
|
-
releases,
|
|
1284
|
-
session,
|
|
1285
|
-
};
|
|
1286
|
-
}
|
|
1287
|
-
|
|
1288
|
-
function publicCorrection(value) {
|
|
1289
|
-
return {
|
|
1290
|
-
correction_id: value.correction_id,
|
|
1291
|
-
state: value.state ?? "active",
|
|
1292
|
-
entity_type: value.entity_type,
|
|
1293
|
-
label: value.label,
|
|
1294
|
-
...(value.artist_credit ? { artist_credit: value.artist_credit } : {}),
|
|
1295
|
-
stance: value.stance,
|
|
1296
|
-
occurred_at: value.occurred_at,
|
|
1297
|
-
...(value.note ? { note: value.note } : {}),
|
|
1298
|
-
};
|
|
1299
|
-
}
|
|
1300
|
-
|
|
1301
|
-
function profileSnapshot(store, subjectId) {
|
|
1302
|
-
const status = store.subjectDataStatus({ subjectId });
|
|
1303
|
-
const listening = store.profileSummary({ subjectId, maxItems: 10 });
|
|
1304
|
-
const profile = createListeningHistoryProfileProjection(listening, {
|
|
1305
|
-
maxItems: 10,
|
|
1306
|
-
});
|
|
1307
|
-
return {
|
|
1308
|
-
profile,
|
|
1309
|
-
response: {
|
|
1310
|
-
profile_kind: "private",
|
|
1311
|
-
state: status.state,
|
|
1312
|
-
coverage: safeCoverage(profile),
|
|
1313
|
-
time_machine: safeTimeMachine(profile),
|
|
1314
|
-
listening_pulse: safeMonthlyActivity(profile),
|
|
1315
|
-
listening_seasons: safeListeningSeasons(profile),
|
|
1316
|
-
historical_returns: safeHistoricalReturns(profile),
|
|
1317
|
-
back_to_back: safeBackToBack(profile),
|
|
1318
|
-
continuity: safeContinuity(profile),
|
|
1319
|
-
listening_patterns: safeListeningPatterns(profile),
|
|
1320
|
-
active_corrections: status.active_taste_assertions,
|
|
1321
|
-
corrections: store
|
|
1322
|
-
.listListenerCorrections({ subjectId, limit: 50 })
|
|
1323
|
-
.map(publicCorrection),
|
|
1324
|
-
},
|
|
1325
|
-
};
|
|
1326
|
-
}
|
|
1327
|
-
|
|
1328
|
-
function profileFingerprint(profile) {
|
|
1329
|
-
// An import receipt describes one operation, not the current cumulative profile.
|
|
1330
|
-
const { preview_source, ...currentProfile } = profile;
|
|
1331
|
-
return createHash("sha256").update(JSON.stringify(currentProfile)).digest("hex");
|
|
1332
|
-
}
|
|
1333
|
-
|
|
1334
|
-
function emptyProfileResponse() {
|
|
1335
|
-
return {
|
|
1336
|
-
state: "empty",
|
|
1337
|
-
coverage: {
|
|
1338
|
-
effective_listening_events: 0,
|
|
1339
|
-
listening_hours: 0,
|
|
1340
|
-
distinct_tracks: 0,
|
|
1341
|
-
cross_format_track_links: 0,
|
|
1342
|
-
cross_format_linked_events: 0,
|
|
1343
|
-
cross_format_ambiguous_tracks: 0,
|
|
1344
|
-
cross_format_ambiguous_events: 0,
|
|
1345
|
-
},
|
|
1346
|
-
time_machine: safeTimeMachine(null),
|
|
1347
|
-
listening_pulse: safeMonthlyActivity(null),
|
|
1348
|
-
listening_seasons: safeListeningSeasons(null),
|
|
1349
|
-
historical_returns: safeHistoricalReturns(null),
|
|
1350
|
-
back_to_back: safeBackToBack(null),
|
|
1351
|
-
continuity: safeContinuity(null),
|
|
1352
|
-
listening_patterns: safeListeningPatterns(null),
|
|
1353
|
-
active_corrections: 0,
|
|
1354
|
-
corrections: [],
|
|
1355
|
-
tasteprint_url: null,
|
|
1356
|
-
tasteprint_card_url: null,
|
|
1357
|
-
};
|
|
1358
|
-
}
|
|
1359
|
-
|
|
1360
|
-
function studioTimestamp(now) {
|
|
1361
|
-
const value = now();
|
|
1362
|
-
if (!(value instanceof Date) || Number.isNaN(value.valueOf())) {
|
|
1363
|
-
throw new TypeError("Moondog Studio clock is invalid.");
|
|
1364
|
-
}
|
|
1365
|
-
return value.toISOString();
|
|
1366
|
-
}
|
|
1367
|
-
|
|
1368
|
-
function publicImportError(error) {
|
|
1369
|
-
if (error instanceof StudioHttpError) return error;
|
|
1370
|
-
if (error instanceof TypeError) {
|
|
1371
|
-
return new StudioHttpError(
|
|
1372
|
-
422,
|
|
1373
|
-
"Choose one or two distinct readable Spotify history ZIP archives.",
|
|
1374
|
-
);
|
|
1375
|
-
}
|
|
1376
|
-
if (
|
|
1377
|
-
typeof error?.code === "string" &&
|
|
1378
|
-
(error.code.startsWith("spotify_") ||
|
|
1379
|
-
error.code.startsWith("listenbrainz_"))
|
|
1380
|
-
) {
|
|
1381
|
-
return new StudioHttpError(422, error.message);
|
|
1382
|
-
}
|
|
1383
|
-
if (error?.code === "projection_source_invalid") {
|
|
1384
|
-
return new StudioHttpError(
|
|
1385
|
-
409,
|
|
1386
|
-
"The existing Apple Music source must be repaired before this import can join the same local identity.",
|
|
1387
|
-
);
|
|
1388
|
-
}
|
|
1389
|
-
return new StudioHttpError(
|
|
1390
|
-
500,
|
|
1391
|
-
"Moondog could not complete the local import. The temporary source copy was removed.",
|
|
1392
|
-
);
|
|
1393
|
-
}
|
|
1394
|
-
|
|
1395
|
-
function publicProfileError(error) {
|
|
1396
|
-
if (error instanceof StudioHttpError) return error;
|
|
1397
|
-
if (error?.code === "listener_correction_not_found") {
|
|
1398
|
-
return new StudioHttpError(404, "That profile correction was not found.");
|
|
1399
|
-
}
|
|
1400
|
-
if (
|
|
1401
|
-
error?.code === "listener_correction_inactive" ||
|
|
1402
|
-
error?.code === "listener_correction_time_invalid"
|
|
1403
|
-
) {
|
|
1404
|
-
return new StudioHttpError(409, error.message);
|
|
1405
|
-
}
|
|
1406
|
-
if (error instanceof TypeError) {
|
|
1407
|
-
return new StudioHttpError(
|
|
1408
|
-
422,
|
|
1409
|
-
"Choose a valid artist or track, a like or avoid stance, and keep the text within the displayed limits.",
|
|
1410
|
-
);
|
|
1411
|
-
}
|
|
1412
|
-
return new StudioHttpError(
|
|
1413
|
-
500,
|
|
1414
|
-
"Moondog could not finish the private profile update. Check the current correction list before retrying.",
|
|
1415
|
-
);
|
|
1416
|
-
}
|
|
1417
|
-
|
|
1418
|
-
async function openBrowser(url) {
|
|
1419
|
-
const [command, args] = process.platform === "darwin"
|
|
1420
|
-
? ["open", [url]]
|
|
1421
|
-
: process.platform === "win32"
|
|
1422
|
-
? ["rundll32", ["url.dll,FileProtocolHandler", url]]
|
|
1423
|
-
: ["xdg-open", [url]];
|
|
1424
|
-
await new Promise((resolve, reject) => {
|
|
1425
|
-
const child = spawn(command, args, {
|
|
1426
|
-
detached: true,
|
|
1427
|
-
stdio: "ignore",
|
|
1428
|
-
windowsHide: true,
|
|
1429
|
-
});
|
|
1430
|
-
child.once("error", reject);
|
|
1431
|
-
child.once("spawn", () => {
|
|
1432
|
-
child.unref();
|
|
1433
|
-
resolve();
|
|
1434
|
-
});
|
|
1435
|
-
});
|
|
1436
|
-
}
|
|
1437
|
-
|
|
1438
|
-
export async function startMoondogStudio({
|
|
1439
|
-
host = MOONDOG_STUDIO_HOST,
|
|
1440
|
-
port = 0,
|
|
1441
|
-
demoOnly = false,
|
|
1442
|
-
archivePath = null,
|
|
1443
|
-
archivePaths = null,
|
|
1444
|
-
environment = process.env,
|
|
1445
|
-
maximumArchiveBytes = MOONDOG_STUDIO_MAXIMUM_ARCHIVE_BYTES,
|
|
1446
|
-
maximumHistoryJsonBytes = MOONDOG_STUDIO_MAXIMUM_HISTORY_JSON_BYTES,
|
|
1447
|
-
temporaryRoot = tmpdir(),
|
|
1448
|
-
brandImagePath = defaultBrandImagePath,
|
|
1449
|
-
now = () => new Date(),
|
|
1450
|
-
openStore = openListeningHistoryStore,
|
|
1451
|
-
importArchive = persistTasteFromSpotifyArchive,
|
|
1452
|
-
importListenBrainzHistory = persistTasteFromListenBrainzHistory,
|
|
1453
|
-
createDemo = createFictionalHistoryDemo,
|
|
1454
|
-
projectArchive = projectTasteFromSpotifyArchive,
|
|
1455
|
-
projectArchives = projectTasteFromSpotifyArchives,
|
|
1456
|
-
writeArtifact = writeTasteprintArtifact,
|
|
1457
|
-
} = {}) {
|
|
1458
|
-
if (host !== MOONDOG_STUDIO_HOST) {
|
|
1459
|
-
throw new TypeError("Moondog Studio may only listen on 127.0.0.1.");
|
|
1460
|
-
}
|
|
1461
|
-
if (!Number.isInteger(port) || port < 0 || port > 65_535) {
|
|
1462
|
-
throw new TypeError("Moondog Studio port is invalid.");
|
|
1463
|
-
}
|
|
1464
|
-
if (typeof demoOnly !== "boolean") {
|
|
1465
|
-
throw new TypeError("Moondog Studio demo-only mode is invalid.");
|
|
1466
|
-
}
|
|
1467
|
-
const selectedArchivePaths = selectedSpotifyArchivePaths({
|
|
1468
|
-
archivePath,
|
|
1469
|
-
archivePaths,
|
|
1470
|
-
});
|
|
1471
|
-
if (demoOnly && selectedArchivePaths.length > 0) {
|
|
1472
|
-
throw new TypeError(
|
|
1473
|
-
"Moondog Studio cannot combine a fictional tour with a private archive session.",
|
|
1474
|
-
);
|
|
1475
|
-
}
|
|
1476
|
-
if (!Number.isSafeInteger(maximumArchiveBytes) || maximumArchiveBytes < 4) {
|
|
1477
|
-
throw new TypeError("Moondog Studio archive size limit is invalid.");
|
|
1478
|
-
}
|
|
1479
|
-
if (
|
|
1480
|
-
!Number.isSafeInteger(maximumHistoryJsonBytes) ||
|
|
1481
|
-
maximumHistoryJsonBytes < 1
|
|
1482
|
-
) {
|
|
1483
|
-
throw new TypeError("Moondog Studio history JSON size limit is invalid.");
|
|
1484
|
-
}
|
|
1485
|
-
if (typeof temporaryRoot !== "string" || !path.isAbsolute(temporaryRoot)) {
|
|
1486
|
-
throw new TypeError("Moondog Studio temporary root must be absolute.");
|
|
1487
|
-
}
|
|
1488
|
-
|
|
1489
|
-
const sessionToken = sessionSecret();
|
|
1490
|
-
const archiveSession = selectedArchivePaths.length > 0;
|
|
1491
|
-
const brandImage = await readFile(brandImagePath);
|
|
1492
|
-
const demoTasteprint = renderTasteprintHtml(
|
|
1493
|
-
createPublicTasteprintDemoProfile(),
|
|
1494
|
-
{
|
|
1495
|
-
generatedAt: PUBLIC_TASTEPRINT_DEMO_GENERATED_AT,
|
|
1496
|
-
syntheticDemo: true,
|
|
1497
|
-
},
|
|
1498
|
-
);
|
|
1499
|
-
const demoTasteprintCard = renderTasteprintCardHtml(
|
|
1500
|
-
createPublicTasteprintDemoProfile(),
|
|
1501
|
-
{
|
|
1502
|
-
generatedAt: PUBLIC_TASTEPRINT_DEMO_GENERATED_AT,
|
|
1503
|
-
syntheticDemo: true,
|
|
1504
|
-
},
|
|
1505
|
-
);
|
|
1506
|
-
let origin = null;
|
|
1507
|
-
let profileChanging = false;
|
|
1508
|
-
let latestArtifact = null;
|
|
1509
|
-
let demoStarted = demoOnly || archiveSession;
|
|
1510
|
-
let interactiveSessionPromise = null;
|
|
1511
|
-
|
|
1512
|
-
const loadInteractiveSession = () => {
|
|
1513
|
-
if (interactiveSessionPromise) return interactiveSessionPromise;
|
|
1514
|
-
const loaded = archiveSession
|
|
1515
|
-
? createPrivateArchiveSession({
|
|
1516
|
-
archivePaths: selectedArchivePaths,
|
|
1517
|
-
maximumArchiveBytes,
|
|
1518
|
-
capturedAt: studioTimestamp(now),
|
|
1519
|
-
projectArchive,
|
|
1520
|
-
projectArchives,
|
|
1521
|
-
})
|
|
1522
|
-
: createDemo({ temporaryRoot });
|
|
1523
|
-
interactiveSessionPromise = loaded
|
|
1524
|
-
.then(({ profile, proof, source }) => ({
|
|
1525
|
-
profileKind: archiveSession ? "private_session" : "synthetic_demo",
|
|
1526
|
-
...(proof ? { proof } : {}),
|
|
1527
|
-
...(source ? { source } : {}),
|
|
1528
|
-
session: new InteractiveTasteprintDemoSession({
|
|
1529
|
-
baseProfileFactory: () => structuredClone(profile),
|
|
1530
|
-
kind: archiveSession ? "private_session" : "demo",
|
|
1531
|
-
}),
|
|
1532
|
-
}))
|
|
1533
|
-
.catch((error) => {
|
|
1534
|
-
interactiveSessionPromise = null;
|
|
1535
|
-
throw error;
|
|
1536
|
-
});
|
|
1537
|
-
return interactiveSessionPromise;
|
|
1538
|
-
};
|
|
1539
|
-
|
|
1540
|
-
const latestTasteprintUrl = () => latestArtifact
|
|
1541
|
-
? `/tasteprint?session=${sessionToken}&artifact=${latestArtifact.id}`
|
|
1542
|
-
: null;
|
|
1543
|
-
const latestTasteprintCardUrl = () => latestArtifact
|
|
1544
|
-
? `/tasteprint-card?session=${sessionToken}&artifact=${latestArtifact.id}`
|
|
1545
|
-
: null;
|
|
1546
|
-
|
|
1547
|
-
const keepRenderedTasteprint = (
|
|
1548
|
-
profile,
|
|
1549
|
-
generatedAt,
|
|
1550
|
-
{ kind = "private", syntheticDemo = false } = {},
|
|
1551
|
-
) => {
|
|
1552
|
-
const artifactId = sessionSecret();
|
|
1553
|
-
latestArtifact = {
|
|
1554
|
-
id: artifactId,
|
|
1555
|
-
kind,
|
|
1556
|
-
profileFingerprint: profileFingerprint(profile),
|
|
1557
|
-
html: Buffer.from(
|
|
1558
|
-
renderTasteprintHtml(profile, { generatedAt, syntheticDemo }),
|
|
1559
|
-
),
|
|
1560
|
-
cardHtml: Buffer.from(
|
|
1561
|
-
renderTasteprintCardHtml(profile, { generatedAt, syntheticDemo }),
|
|
1562
|
-
),
|
|
1563
|
-
};
|
|
1564
|
-
return latestTasteprintUrl();
|
|
1565
|
-
};
|
|
1566
|
-
|
|
1567
|
-
const writeRenderedTasteprint = async (profile, generatedAt) => {
|
|
1568
|
-
const cardHtml = Buffer.from(
|
|
1569
|
-
renderTasteprintCardHtml(profile, { generatedAt }),
|
|
1570
|
-
);
|
|
1571
|
-
const artifact = await writeArtifact(profile, {
|
|
1572
|
-
environment,
|
|
1573
|
-
generatedAt,
|
|
1574
|
-
});
|
|
1575
|
-
latestArtifact = {
|
|
1576
|
-
id: sessionSecret(),
|
|
1577
|
-
kind: "private",
|
|
1578
|
-
profileFingerprint: profileFingerprint(profile),
|
|
1579
|
-
path: artifact.path,
|
|
1580
|
-
cardHtml,
|
|
1581
|
-
};
|
|
1582
|
-
return latestTasteprintUrl();
|
|
1583
|
-
};
|
|
1584
|
-
|
|
1585
|
-
const readPrivateProfile = async () => {
|
|
1586
|
-
if (demoOnly || archiveSession) return null;
|
|
1587
|
-
const clearPrivateArtifact = () => {
|
|
1588
|
-
if (latestArtifact?.kind === "private") latestArtifact = null;
|
|
1589
|
-
return null;
|
|
1590
|
-
};
|
|
1591
|
-
const databasePath = resolveListeningHistoryPath(environment);
|
|
1592
|
-
if (!(await pathExists(databasePath))) return clearPrivateArtifact();
|
|
1593
|
-
const store = await openStore({ environment });
|
|
1594
|
-
try {
|
|
1595
|
-
const subjectId = store.localSubjectId();
|
|
1596
|
-
if (!subjectId) return clearPrivateArtifact();
|
|
1597
|
-
const snapshot = profileSnapshot(store, subjectId);
|
|
1598
|
-
if (snapshot.response.state !== "ready") return clearPrivateArtifact();
|
|
1599
|
-
if (
|
|
1600
|
-
latestArtifact?.kind !== "private" ||
|
|
1601
|
-
latestArtifact.profileFingerprint !== profileFingerprint(snapshot.profile)
|
|
1602
|
-
) {
|
|
1603
|
-
keepRenderedTasteprint(snapshot.profile, studioTimestamp(now), {
|
|
1604
|
-
kind: "private",
|
|
1605
|
-
});
|
|
1606
|
-
}
|
|
1607
|
-
return {
|
|
1608
|
-
...snapshot.response,
|
|
1609
|
-
tasteprint_url: latestTasteprintUrl(),
|
|
1610
|
-
tasteprint_card_url: latestTasteprintCardUrl(),
|
|
1611
|
-
};
|
|
1612
|
-
} finally {
|
|
1613
|
-
store.close();
|
|
1614
|
-
}
|
|
1615
|
-
};
|
|
1616
|
-
|
|
1617
|
-
const interactiveProfileResponse = (interactive, profile) => ({
|
|
1618
|
-
profile_kind: interactive.profileKind,
|
|
1619
|
-
...(interactive.proof ? { demo_proof: interactive.proof } : {}),
|
|
1620
|
-
...(interactive.source ? { source: interactive.source } : {}),
|
|
1621
|
-
state: "ready",
|
|
1622
|
-
coverage: safeCoverage(profile),
|
|
1623
|
-
time_machine: safeTimeMachine(profile),
|
|
1624
|
-
listening_pulse: safeMonthlyActivity(profile),
|
|
1625
|
-
listening_seasons: safeListeningSeasons(profile),
|
|
1626
|
-
historical_returns: safeHistoricalReturns(profile),
|
|
1627
|
-
back_to_back: safeBackToBack(profile),
|
|
1628
|
-
continuity: safeContinuity(profile),
|
|
1629
|
-
listening_patterns: safeListeningPatterns(profile),
|
|
1630
|
-
active_corrections: interactive.session.activeCount,
|
|
1631
|
-
corrections: interactive.session.corrections(),
|
|
1632
|
-
tasteprint_url: latestTasteprintUrl(),
|
|
1633
|
-
tasteprint_card_url: latestTasteprintCardUrl(),
|
|
1634
|
-
});
|
|
1635
|
-
|
|
1636
|
-
const readInteractiveProfile = async () => {
|
|
1637
|
-
const interactive = await loadInteractiveSession();
|
|
1638
|
-
const profile = interactive.session.profile();
|
|
1639
|
-
if (latestArtifact?.kind !== interactive.profileKind) {
|
|
1640
|
-
keepRenderedTasteprint(profile, studioTimestamp(now), {
|
|
1641
|
-
kind: interactive.profileKind,
|
|
1642
|
-
syntheticDemo: interactive.profileKind === "synthetic_demo",
|
|
1643
|
-
});
|
|
1644
|
-
}
|
|
1645
|
-
return interactiveProfileResponse(interactive, profile);
|
|
1646
|
-
};
|
|
1647
|
-
|
|
1648
|
-
const refreshInteractiveProfile = async (generatedAt) => {
|
|
1649
|
-
const interactive = await loadInteractiveSession();
|
|
1650
|
-
const profile = interactive.session.profile();
|
|
1651
|
-
const tasteprintUrl = keepRenderedTasteprint(profile, generatedAt, {
|
|
1652
|
-
kind: interactive.profileKind,
|
|
1653
|
-
syntheticDemo: interactive.profileKind === "synthetic_demo",
|
|
1654
|
-
});
|
|
1655
|
-
return {
|
|
1656
|
-
...interactiveProfileResponse(interactive, profile),
|
|
1657
|
-
tasteprint_url: tasteprintUrl,
|
|
1658
|
-
};
|
|
1659
|
-
};
|
|
1660
|
-
|
|
1661
|
-
const readCurrentProfile = async () =>
|
|
1662
|
-
(await readPrivateProfile()) ??
|
|
1663
|
-
(demoStarted ? await readInteractiveProfile() : emptyProfileResponse());
|
|
1664
|
-
|
|
1665
|
-
if (archiveSession) await loadInteractiveSession();
|
|
1666
|
-
|
|
1667
|
-
const server = createServer(async (request, response) => {
|
|
1668
|
-
let requestUrl;
|
|
1669
|
-
try {
|
|
1670
|
-
requestUrl = new URL(request.url ?? "/", origin);
|
|
1671
|
-
} catch {
|
|
1672
|
-
sendNotFound(response);
|
|
1673
|
-
return;
|
|
1674
|
-
}
|
|
1675
|
-
const queryToken = requestUrl.searchParams.get("session");
|
|
1676
|
-
|
|
1677
|
-
if (request.method === "GET" && requestUrl.pathname === "/") {
|
|
1678
|
-
if (!validToken(queryToken, sessionToken)) {
|
|
1679
|
-
sendNotFound(response);
|
|
1680
|
-
return;
|
|
1681
|
-
}
|
|
1682
|
-
const nonce = randomBytes(18).toString("base64url");
|
|
1683
|
-
const requestedCapture = requestUrl.searchParams.get("capture");
|
|
1684
|
-
const html = renderStudioPage({
|
|
1685
|
-
sessionToken,
|
|
1686
|
-
nonce,
|
|
1687
|
-
maximumArchiveBytes,
|
|
1688
|
-
maximumHistoryJsonBytes,
|
|
1689
|
-
demoOnly,
|
|
1690
|
-
archiveSession,
|
|
1691
|
-
archiveCount: selectedArchivePaths.length,
|
|
1692
|
-
captureMode: demoOnly && new Set([
|
|
1693
|
-
"time-machine",
|
|
1694
|
-
"listening-pulse",
|
|
1695
|
-
"listening-seasons",
|
|
1696
|
-
"historical-returns",
|
|
1697
|
-
"listening-patterns",
|
|
1698
|
-
"correction",
|
|
1699
|
-
]).has(requestedCapture)
|
|
1700
|
-
? requestedCapture
|
|
1701
|
-
: demoOnly && new Set(["1", "hero"]).has(requestedCapture)
|
|
1702
|
-
? "hero"
|
|
1703
|
-
: null,
|
|
1704
|
-
});
|
|
1705
|
-
send(response, 200, html, {
|
|
1706
|
-
...baseHeaders("text/html; charset=utf-8"),
|
|
1707
|
-
"Content-Security-Policy": [
|
|
1708
|
-
"default-src 'none'",
|
|
1709
|
-
`style-src 'nonce-${nonce}'`,
|
|
1710
|
-
`script-src 'nonce-${nonce}'`,
|
|
1711
|
-
"img-src 'self'",
|
|
1712
|
-
"connect-src 'self'",
|
|
1713
|
-
"object-src 'none'",
|
|
1714
|
-
"base-uri 'none'",
|
|
1715
|
-
"form-action 'none'",
|
|
1716
|
-
"frame-ancestors 'none'",
|
|
1717
|
-
].join("; "),
|
|
1718
|
-
});
|
|
1719
|
-
return;
|
|
1720
|
-
}
|
|
1721
|
-
|
|
1722
|
-
if (request.method === "GET" && requestUrl.pathname === "/brand.png") {
|
|
1723
|
-
if (!validToken(queryToken, sessionToken)) {
|
|
1724
|
-
sendNotFound(response);
|
|
1725
|
-
return;
|
|
1726
|
-
}
|
|
1727
|
-
send(response, 200, brandImage, {
|
|
1728
|
-
...baseHeaders("image/png"),
|
|
1729
|
-
"Content-Security-Policy": "default-src 'none'; frame-ancestors 'none'",
|
|
1730
|
-
});
|
|
1731
|
-
return;
|
|
1732
|
-
}
|
|
1733
|
-
|
|
1734
|
-
if (
|
|
1735
|
-
request.method === "GET" &&
|
|
1736
|
-
requestUrl.pathname === "/demo-tasteprint"
|
|
1737
|
-
) {
|
|
1738
|
-
if (!validToken(queryToken, sessionToken)) {
|
|
1739
|
-
sendNotFound(response);
|
|
1740
|
-
return;
|
|
1741
|
-
}
|
|
1742
|
-
send(response, 200, demoTasteprint, {
|
|
1743
|
-
...baseHeaders("text/html; charset=utf-8"),
|
|
1744
|
-
"Content-Disposition": "inline; filename=\"moondog-fictional-tasteprint.html\"",
|
|
1745
|
-
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; img-src data:; connect-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
|
|
1746
|
-
});
|
|
1747
|
-
return;
|
|
1748
|
-
}
|
|
1749
|
-
|
|
1750
|
-
if (
|
|
1751
|
-
request.method === "GET" &&
|
|
1752
|
-
requestUrl.pathname === "/demo-tasteprint-card"
|
|
1753
|
-
) {
|
|
1754
|
-
if (!validToken(queryToken, sessionToken)) {
|
|
1755
|
-
sendNotFound(response);
|
|
1756
|
-
return;
|
|
1757
|
-
}
|
|
1758
|
-
const download = requestUrl.searchParams.get("download") === "1";
|
|
1759
|
-
send(response, 200, demoTasteprintCard, {
|
|
1760
|
-
...baseHeaders("text/html; charset=utf-8"),
|
|
1761
|
-
"Content-Disposition": `${download ? "attachment" : "inline"}; filename="moondog-fictional-tasteprint-card.html"`,
|
|
1762
|
-
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; img-src data:; connect-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
|
|
1763
|
-
});
|
|
1764
|
-
return;
|
|
1765
|
-
}
|
|
1766
|
-
|
|
1767
|
-
if (
|
|
1768
|
-
request.method === "GET" &&
|
|
1769
|
-
new Set(["/tasteprint", "/tasteprint-card"]).has(requestUrl.pathname)
|
|
1770
|
-
) {
|
|
1771
|
-
const artifactId = requestUrl.searchParams.get("artifact");
|
|
1772
|
-
if (
|
|
1773
|
-
!validToken(queryToken, sessionToken) ||
|
|
1774
|
-
!latestArtifact ||
|
|
1775
|
-
!validToken(artifactId, latestArtifact.id)
|
|
1776
|
-
) {
|
|
1777
|
-
sendNotFound(response);
|
|
1778
|
-
return;
|
|
1779
|
-
}
|
|
1780
|
-
try {
|
|
1781
|
-
const cardRequested = requestUrl.pathname === "/tasteprint-card";
|
|
1782
|
-
const download = cardRequested &&
|
|
1783
|
-
requestUrl.searchParams.get("download") === "1";
|
|
1784
|
-
const html = cardRequested
|
|
1785
|
-
? latestArtifact.cardHtml
|
|
1786
|
-
: latestArtifact.html ?? await readFile(latestArtifact.path);
|
|
1787
|
-
if (!html) throw new Error("The requested Studio artifact is unavailable.");
|
|
1788
|
-
send(response, 200, html, {
|
|
1789
|
-
...baseHeaders("text/html; charset=utf-8"),
|
|
1790
|
-
"Content-Disposition": download
|
|
1791
|
-
? `attachment; filename="${latestArtifact.kind === "synthetic_demo"
|
|
1792
|
-
? "moondog-fictional"
|
|
1793
|
-
: latestArtifact.kind === "private_session"
|
|
1794
|
-
? "moondog-private-session"
|
|
1795
|
-
: "moondog-private"}-tasteprint-card.html"`
|
|
1796
|
-
: "inline",
|
|
1797
|
-
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; img-src data:; connect-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
|
|
1798
|
-
});
|
|
1799
|
-
} catch {
|
|
1800
|
-
sendNotFound(response);
|
|
1801
|
-
}
|
|
1802
|
-
return;
|
|
1803
|
-
}
|
|
1804
|
-
|
|
1805
|
-
if (request.method === "GET" && requestUrl.pathname === "/api/profile") {
|
|
1806
|
-
const headerToken = request.headers["x-moondog-session"];
|
|
1807
|
-
if (
|
|
1808
|
-
Array.isArray(headerToken) ||
|
|
1809
|
-
!validToken(headerToken, sessionToken)
|
|
1810
|
-
) {
|
|
1811
|
-
sendNotFound(response);
|
|
1812
|
-
return;
|
|
1813
|
-
}
|
|
1814
|
-
try {
|
|
1815
|
-
sendJson(response, 200, await readCurrentProfile());
|
|
1816
|
-
} catch (error) {
|
|
1817
|
-
const publicError = publicProfileError(error);
|
|
1818
|
-
sendJson(response, publicError.statusCode, {
|
|
1819
|
-
state: "error",
|
|
1820
|
-
message: publicError.message,
|
|
1821
|
-
});
|
|
1822
|
-
}
|
|
1823
|
-
return;
|
|
1824
|
-
}
|
|
1825
|
-
|
|
1826
|
-
if (request.method === "POST" && requestUrl.pathname === "/api/import") {
|
|
1827
|
-
const headerToken = request.headers["x-moondog-session"];
|
|
1828
|
-
if (
|
|
1829
|
-
Array.isArray(headerToken) ||
|
|
1830
|
-
!validToken(headerToken, sessionToken) ||
|
|
1831
|
-
request.headers.origin !== origin
|
|
1832
|
-
) {
|
|
1833
|
-
request.resume();
|
|
1834
|
-
sendNotFound(response);
|
|
1835
|
-
return;
|
|
1836
|
-
}
|
|
1837
|
-
if (demoOnly) {
|
|
1838
|
-
request.resume();
|
|
1839
|
-
sendJson(response, 409, {
|
|
1840
|
-
state: "error",
|
|
1841
|
-
message: "The fictional Studio tour does not read or accept private listening history.",
|
|
1842
|
-
});
|
|
1843
|
-
return;
|
|
1844
|
-
}
|
|
1845
|
-
if (archiveSession) {
|
|
1846
|
-
request.resume();
|
|
1847
|
-
sendJson(response, 409, {
|
|
1848
|
-
state: "error",
|
|
1849
|
-
message: "This session-only Studio already read the explicitly supplied ZIP and does not accept another import.",
|
|
1850
|
-
});
|
|
1851
|
-
return;
|
|
1852
|
-
}
|
|
1853
|
-
const rawContentType = request.headers["content-type"];
|
|
1854
|
-
const contentType = Array.isArray(rawContentType)
|
|
1855
|
-
? ""
|
|
1856
|
-
: rawContentType?.split(";", 1)[0].trim().toLocaleLowerCase("en-US");
|
|
1857
|
-
const importKind = contentType === "application/zip"
|
|
1858
|
-
? "spotify"
|
|
1859
|
-
: contentType === "application/json"
|
|
1860
|
-
? "listenbrainz"
|
|
1861
|
-
: null;
|
|
1862
|
-
if (!importKind) {
|
|
1863
|
-
request.resume();
|
|
1864
|
-
sendJson(response, 415, {
|
|
1865
|
-
state: "error",
|
|
1866
|
-
message: "Choose a Spotify .zip or ListenBrainz .json history file.",
|
|
1867
|
-
});
|
|
1868
|
-
return;
|
|
1869
|
-
}
|
|
1870
|
-
if (profileChanging) {
|
|
1871
|
-
request.resume();
|
|
1872
|
-
sendJson(response, 409, {
|
|
1873
|
-
state: "error",
|
|
1874
|
-
message: "Another local profile update is already running.",
|
|
1875
|
-
});
|
|
1876
|
-
return;
|
|
1877
|
-
}
|
|
1878
|
-
|
|
1879
|
-
profileChanging = true;
|
|
1880
|
-
let uploadRoot = null;
|
|
1881
|
-
let store = null;
|
|
1882
|
-
try {
|
|
1883
|
-
const upload = importKind === "spotify"
|
|
1884
|
-
? await receivePrivateZip(request, {
|
|
1885
|
-
maximumArchiveBytes,
|
|
1886
|
-
temporaryRoot,
|
|
1887
|
-
})
|
|
1888
|
-
: await receivePrivateListenBrainzJson(request, {
|
|
1889
|
-
maximumHistoryJsonBytes,
|
|
1890
|
-
temporaryRoot,
|
|
1891
|
-
});
|
|
1892
|
-
uploadRoot = upload.uploadRoot;
|
|
1893
|
-
const generatedAt = studioTimestamp(now);
|
|
1894
|
-
store = await openStore({ environment });
|
|
1895
|
-
const subjectId = await trustedLocalSubjectId(store);
|
|
1896
|
-
const imported = importKind === "spotify"
|
|
1897
|
-
? await importArchive({
|
|
1898
|
-
archivePath: upload.archivePath,
|
|
1899
|
-
subjectId,
|
|
1900
|
-
store,
|
|
1901
|
-
capturedAt: generatedAt,
|
|
1902
|
-
maxItems: 10,
|
|
1903
|
-
})
|
|
1904
|
-
: await importListenBrainzHistory({
|
|
1905
|
-
historyPath: upload.historyPath,
|
|
1906
|
-
subjectId,
|
|
1907
|
-
store,
|
|
1908
|
-
capturedAt: generatedAt,
|
|
1909
|
-
maxItems: 10,
|
|
1910
|
-
});
|
|
1911
|
-
const profileStatus = store.subjectDataStatus({ subjectId });
|
|
1912
|
-
const corrections = store
|
|
1913
|
-
.listListenerCorrections({ subjectId, limit: 50 })
|
|
1914
|
-
.map(publicCorrection);
|
|
1915
|
-
store.close();
|
|
1916
|
-
store = null;
|
|
1917
|
-
const profile = {
|
|
1918
|
-
...imported.profile,
|
|
1919
|
-
preview_source: imported.source,
|
|
1920
|
-
};
|
|
1921
|
-
const tasteprintUrl = await writeRenderedTasteprint(profile, generatedAt);
|
|
1922
|
-
demoStarted = false;
|
|
1923
|
-
await rm(uploadRoot, { recursive: true, force: true });
|
|
1924
|
-
uploadRoot = null;
|
|
1925
|
-
sendJson(response, 200, {
|
|
1926
|
-
profile_kind: "private",
|
|
1927
|
-
state: "ready",
|
|
1928
|
-
coverage: safeCoverage(profile),
|
|
1929
|
-
time_machine: safeTimeMachine(profile),
|
|
1930
|
-
listening_pulse: safeMonthlyActivity(profile),
|
|
1931
|
-
listening_seasons: safeListeningSeasons(profile),
|
|
1932
|
-
historical_returns: safeHistoricalReturns(profile),
|
|
1933
|
-
back_to_back: safeBackToBack(profile),
|
|
1934
|
-
continuity: safeContinuity(profile),
|
|
1935
|
-
listening_patterns: safeListeningPatterns(profile),
|
|
1936
|
-
source: {
|
|
1937
|
-
provider: importKind,
|
|
1938
|
-
data_scope: imported.source.data_scope,
|
|
1939
|
-
already_imported: imported.source.already_imported === true,
|
|
1940
|
-
effective_event_delta: Number.isInteger(
|
|
1941
|
-
imported.source.effective_event_delta,
|
|
1942
|
-
)
|
|
1943
|
-
? imported.source.effective_event_delta
|
|
1944
|
-
: 0,
|
|
1945
|
-
...(importKind === "spotify"
|
|
1946
|
-
? {
|
|
1947
|
-
superseded_events: Number.isInteger(
|
|
1948
|
-
imported.source.superseded_events,
|
|
1949
|
-
)
|
|
1950
|
-
? imported.source.superseded_events
|
|
1951
|
-
: 0,
|
|
1952
|
-
}
|
|
1953
|
-
: {
|
|
1954
|
-
mapped_track_refs: Number.isInteger(
|
|
1955
|
-
imported.source.mapped_track_refs,
|
|
1956
|
-
)
|
|
1957
|
-
? imported.source.mapped_track_refs
|
|
1958
|
-
: 0,
|
|
1959
|
-
events_with_played_duration: Number.isInteger(
|
|
1960
|
-
imported.source.events_with_played_duration,
|
|
1961
|
-
)
|
|
1962
|
-
? imported.source.events_with_played_duration
|
|
1963
|
-
: 0,
|
|
1964
|
-
}),
|
|
1965
|
-
},
|
|
1966
|
-
active_corrections: profileStatus.active_taste_assertions,
|
|
1967
|
-
corrections,
|
|
1968
|
-
tasteprint_url: tasteprintUrl,
|
|
1969
|
-
tasteprint_card_url: latestTasteprintCardUrl(),
|
|
1970
|
-
});
|
|
1971
|
-
} catch (error) {
|
|
1972
|
-
request.resume();
|
|
1973
|
-
store?.close?.();
|
|
1974
|
-
store = null;
|
|
1975
|
-
if (uploadRoot) {
|
|
1976
|
-
await rm(uploadRoot, { recursive: true, force: true }).catch(() => {});
|
|
1977
|
-
uploadRoot = null;
|
|
1978
|
-
}
|
|
1979
|
-
const publicError = publicImportError(error);
|
|
1980
|
-
sendJson(response, publicError.statusCode, {
|
|
1981
|
-
state: "error",
|
|
1982
|
-
message: publicError.message,
|
|
1983
|
-
});
|
|
1984
|
-
} finally {
|
|
1985
|
-
store?.close?.();
|
|
1986
|
-
if (uploadRoot) {
|
|
1987
|
-
await rm(uploadRoot, { recursive: true, force: true }).catch(() => {});
|
|
1988
|
-
}
|
|
1989
|
-
profileChanging = false;
|
|
1990
|
-
}
|
|
1991
|
-
return;
|
|
1992
|
-
}
|
|
1993
|
-
|
|
1994
|
-
if (
|
|
1995
|
-
request.method === "POST" &&
|
|
1996
|
-
new Set([
|
|
1997
|
-
"/api/corrections",
|
|
1998
|
-
"/api/corrections/retract",
|
|
1999
|
-
"/api/demo/start",
|
|
2000
|
-
"/api/demo/corrections",
|
|
2001
|
-
"/api/demo/corrections/retract",
|
|
2002
|
-
"/api/session/corrections",
|
|
2003
|
-
"/api/session/corrections/retract",
|
|
2004
|
-
]).has(requestUrl.pathname)
|
|
2005
|
-
) {
|
|
2006
|
-
const headerToken = request.headers["x-moondog-session"];
|
|
2007
|
-
if (
|
|
2008
|
-
Array.isArray(headerToken) ||
|
|
2009
|
-
!validToken(headerToken, sessionToken) ||
|
|
2010
|
-
request.headers.origin !== origin
|
|
2011
|
-
) {
|
|
2012
|
-
request.resume();
|
|
2013
|
-
sendNotFound(response);
|
|
2014
|
-
return;
|
|
2015
|
-
}
|
|
2016
|
-
const contentType = String(request.headers["content-type"] ?? "")
|
|
2017
|
-
.split(";", 1)[0]
|
|
2018
|
-
.trim()
|
|
2019
|
-
.toLocaleLowerCase("en-US");
|
|
2020
|
-
if (contentType !== "application/json") {
|
|
2021
|
-
request.resume();
|
|
2022
|
-
sendJson(response, 415, {
|
|
2023
|
-
state: "error",
|
|
2024
|
-
message: "This local profile update must be JSON.",
|
|
2025
|
-
});
|
|
2026
|
-
return;
|
|
2027
|
-
}
|
|
2028
|
-
const demoRequest = requestUrl.pathname.startsWith("/api/demo/");
|
|
2029
|
-
const sessionRequest = requestUrl.pathname.startsWith("/api/session/");
|
|
2030
|
-
if (demoOnly && !demoRequest) {
|
|
2031
|
-
request.resume();
|
|
2032
|
-
sendJson(response, 409, {
|
|
2033
|
-
state: "error",
|
|
2034
|
-
message: "The fictional Studio tour accepts only in-memory demo corrections.",
|
|
2035
|
-
});
|
|
2036
|
-
return;
|
|
2037
|
-
}
|
|
2038
|
-
if (archiveSession && requestUrl.pathname === "/api/demo/start") {
|
|
2039
|
-
request.resume();
|
|
2040
|
-
sendJson(response, 409, {
|
|
2041
|
-
state: "error",
|
|
2042
|
-
message: "This session-only Studio already contains the supplied private history.",
|
|
2043
|
-
});
|
|
2044
|
-
return;
|
|
2045
|
-
}
|
|
2046
|
-
if (archiveSession && !sessionRequest) {
|
|
2047
|
-
request.resume();
|
|
2048
|
-
sendJson(response, 409, {
|
|
2049
|
-
state: "error",
|
|
2050
|
-
message: "This session-only Studio accepts only in-memory session corrections.",
|
|
2051
|
-
});
|
|
2052
|
-
return;
|
|
2053
|
-
}
|
|
2054
|
-
if (!archiveSession && sessionRequest) {
|
|
2055
|
-
request.resume();
|
|
2056
|
-
sendJson(response, 409, {
|
|
2057
|
-
state: "error",
|
|
2058
|
-
message: "Start Studio with --from before using session-only corrections.",
|
|
2059
|
-
});
|
|
2060
|
-
return;
|
|
2061
|
-
}
|
|
2062
|
-
if (profileChanging) {
|
|
2063
|
-
request.resume();
|
|
2064
|
-
sendJson(response, 409, {
|
|
2065
|
-
state: "error",
|
|
2066
|
-
message: "Another local profile update is already running.",
|
|
2067
|
-
});
|
|
2068
|
-
return;
|
|
2069
|
-
}
|
|
2070
|
-
|
|
2071
|
-
profileChanging = true;
|
|
2072
|
-
let store = null;
|
|
2073
|
-
try {
|
|
2074
|
-
const input = await receiveJson(request);
|
|
2075
|
-
const generatedAt = studioTimestamp(now);
|
|
2076
|
-
if (requestUrl.pathname === "/api/demo/start") {
|
|
2077
|
-
exactObject(input, new Set());
|
|
2078
|
-
if (await readPrivateProfile()) {
|
|
2079
|
-
throw new StudioHttpError(
|
|
2080
|
-
409,
|
|
2081
|
-
"The fictional demo is available only before a private profile is ready.",
|
|
2082
|
-
);
|
|
2083
|
-
}
|
|
2084
|
-
demoStarted = true;
|
|
2085
|
-
sendJson(response, 200, await refreshInteractiveProfile(generatedAt));
|
|
2086
|
-
return;
|
|
2087
|
-
}
|
|
2088
|
-
|
|
2089
|
-
if (demoRequest || sessionRequest) {
|
|
2090
|
-
if (!demoStarted) {
|
|
2091
|
-
throw new StudioHttpError(
|
|
2092
|
-
409,
|
|
2093
|
-
"Start the fictional profile before correcting it.",
|
|
2094
|
-
);
|
|
2095
|
-
}
|
|
2096
|
-
if (!archiveSession && await readPrivateProfile()) {
|
|
2097
|
-
throw new StudioHttpError(
|
|
2098
|
-
409,
|
|
2099
|
-
"A private profile is ready. Apply corrections to that profile instead.",
|
|
2100
|
-
);
|
|
2101
|
-
}
|
|
2102
|
-
const interactive = await loadInteractiveSession();
|
|
2103
|
-
let mutation;
|
|
2104
|
-
if (requestUrl.pathname.endsWith("/corrections")) {
|
|
2105
|
-
const correction = interactive.session.record({
|
|
2106
|
-
...listenerCorrectionInput(input),
|
|
2107
|
-
occurredAt: generatedAt,
|
|
2108
|
-
recordedAt: generatedAt,
|
|
2109
|
-
});
|
|
2110
|
-
mutation = {
|
|
2111
|
-
state: "active",
|
|
2112
|
-
correction,
|
|
2113
|
-
};
|
|
2114
|
-
} else {
|
|
2115
|
-
exactObject(input, new Set(["correction_id"]));
|
|
2116
|
-
mutation = interactive.session.retract({
|
|
2117
|
-
correctionId: input.correction_id,
|
|
2118
|
-
occurredAt: generatedAt,
|
|
2119
|
-
recordedAt: generatedAt,
|
|
2120
|
-
});
|
|
2121
|
-
}
|
|
2122
|
-
sendJson(response, 200, {
|
|
2123
|
-
...(await refreshInteractiveProfile(generatedAt)),
|
|
2124
|
-
mutation,
|
|
2125
|
-
});
|
|
2126
|
-
return;
|
|
2127
|
-
}
|
|
2128
|
-
|
|
2129
|
-
const databasePath = resolveListeningHistoryPath(environment);
|
|
2130
|
-
if (!(await pathExists(databasePath))) {
|
|
2131
|
-
throw new StudioHttpError(
|
|
2132
|
-
409,
|
|
2133
|
-
"Import Spotify history before correcting the profile.",
|
|
2134
|
-
);
|
|
2135
|
-
}
|
|
2136
|
-
store = await openStore({ environment });
|
|
2137
|
-
const subjectId = store.localSubjectId();
|
|
2138
|
-
if (
|
|
2139
|
-
!subjectId ||
|
|
2140
|
-
store.subjectDataStatus({ subjectId }).state !== "ready"
|
|
2141
|
-
) {
|
|
2142
|
-
throw new StudioHttpError(
|
|
2143
|
-
409,
|
|
2144
|
-
"Import Spotify history before correcting the profile.",
|
|
2145
|
-
);
|
|
2146
|
-
}
|
|
2147
|
-
let mutation;
|
|
2148
|
-
if (requestUrl.pathname === "/api/corrections") {
|
|
2149
|
-
const correction = store.recordListenerCorrection({
|
|
2150
|
-
subjectId,
|
|
2151
|
-
...listenerCorrectionInput(input),
|
|
2152
|
-
occurredAt: generatedAt,
|
|
2153
|
-
recordedAt: generatedAt,
|
|
2154
|
-
});
|
|
2155
|
-
mutation = {
|
|
2156
|
-
state: "active",
|
|
2157
|
-
correction: publicCorrection(correction),
|
|
2158
|
-
};
|
|
2159
|
-
} else {
|
|
2160
|
-
exactObject(input, new Set(["correction_id"]));
|
|
2161
|
-
const retraction = store.retractListenerCorrection({
|
|
2162
|
-
subjectId,
|
|
2163
|
-
correctionId: input.correction_id,
|
|
2164
|
-
occurredAt: generatedAt,
|
|
2165
|
-
recordedAt: generatedAt,
|
|
2166
|
-
});
|
|
2167
|
-
mutation = {
|
|
2168
|
-
state: "retracted",
|
|
2169
|
-
correction_id: retraction.correction_id,
|
|
2170
|
-
};
|
|
2171
|
-
}
|
|
2172
|
-
const snapshot = profileSnapshot(store, subjectId);
|
|
2173
|
-
const tasteprintUrl = await writeRenderedTasteprint(
|
|
2174
|
-
snapshot.profile,
|
|
2175
|
-
generatedAt,
|
|
2176
|
-
);
|
|
2177
|
-
sendJson(response, 200, {
|
|
2178
|
-
...snapshot.response,
|
|
2179
|
-
mutation,
|
|
2180
|
-
tasteprint_url: tasteprintUrl,
|
|
2181
|
-
tasteprint_card_url: latestTasteprintCardUrl(),
|
|
2182
|
-
});
|
|
2183
|
-
} catch (error) {
|
|
2184
|
-
request.resume();
|
|
2185
|
-
const publicError = publicProfileError(error);
|
|
2186
|
-
sendJson(response, publicError.statusCode, {
|
|
2187
|
-
state: "error",
|
|
2188
|
-
message: publicError.message,
|
|
2189
|
-
});
|
|
2190
|
-
} finally {
|
|
2191
|
-
store?.close?.();
|
|
2192
|
-
profileChanging = false;
|
|
2193
|
-
}
|
|
2194
|
-
return;
|
|
2195
|
-
}
|
|
2196
|
-
|
|
2197
|
-
request.resume();
|
|
2198
|
-
sendNotFound(response);
|
|
2199
|
-
});
|
|
2200
|
-
|
|
2201
|
-
server.requestTimeout = 10 * 60 * 1000;
|
|
2202
|
-
server.headersTimeout = 30 * 1000;
|
|
2203
|
-
server.maxRequestsPerSocket = 50;
|
|
2204
|
-
server.on("clientError", (_error, socket) => {
|
|
2205
|
-
if (!socket.destroyed) socket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n");
|
|
2206
|
-
});
|
|
2207
|
-
|
|
2208
|
-
await new Promise((resolve, reject) => {
|
|
2209
|
-
const onError = (error) => {
|
|
2210
|
-
server.off("listening", onListening);
|
|
2211
|
-
reject(error);
|
|
2212
|
-
};
|
|
2213
|
-
const onListening = () => {
|
|
2214
|
-
server.off("error", onError);
|
|
2215
|
-
resolve();
|
|
2216
|
-
};
|
|
2217
|
-
server.once("error", onError);
|
|
2218
|
-
server.once("listening", onListening);
|
|
2219
|
-
server.listen(port, host);
|
|
2220
|
-
});
|
|
2221
|
-
const address = server.address();
|
|
2222
|
-
if (!address || typeof address === "string") {
|
|
2223
|
-
await new Promise((resolve) => server.close(resolve));
|
|
2224
|
-
throw new Error("Moondog Studio did not receive a loopback address.");
|
|
2225
|
-
}
|
|
2226
|
-
origin = `http://${host}:${address.port}`;
|
|
2227
|
-
const url = `${origin}/?session=${sessionToken}`;
|
|
2228
|
-
let closed = false;
|
|
2229
|
-
|
|
2230
|
-
return {
|
|
2231
|
-
origin,
|
|
2232
|
-
url,
|
|
2233
|
-
async close() {
|
|
2234
|
-
if (closed) return;
|
|
2235
|
-
closed = true;
|
|
2236
|
-
await new Promise((resolve, reject) => {
|
|
2237
|
-
server.close((error) => (error ? reject(error) : resolve()));
|
|
2238
|
-
});
|
|
2239
|
-
},
|
|
2240
|
-
};
|
|
2241
|
-
}
|
|
2242
|
-
|
|
2243
|
-
export async function runMoondogStudio({
|
|
2244
|
-
demoOnly = false,
|
|
2245
|
-
archivePath = null,
|
|
2246
|
-
archivePaths = null,
|
|
2247
|
-
environment = process.env,
|
|
2248
|
-
stdout = process.stdout,
|
|
2249
|
-
stderr = process.stderr,
|
|
2250
|
-
launchBrowser = openBrowser,
|
|
2251
|
-
} = {}) {
|
|
2252
|
-
const studio = await startMoondogStudio({
|
|
2253
|
-
environment,
|
|
2254
|
-
demoOnly,
|
|
2255
|
-
archivePath,
|
|
2256
|
-
archivePaths,
|
|
2257
|
-
});
|
|
2258
|
-
const archiveCount = archivePaths?.length ?? (archivePath ? 1 : 0);
|
|
2259
|
-
stdout.write(
|
|
2260
|
-
`Moondog Studio is ready at ${studio.url}\n${demoOnly
|
|
2261
|
-
? "Fictional production-importer tour. No personal listening history is read or accepted, and no persistent profile is written."
|
|
2262
|
-
: archiveCount > 0
|
|
2263
|
-
? `Session-only private profile. ${archiveCount === 1 ? "The original ZIP stays" : "Both original ZIPs stay"} untouched, no persistent profile is written, and all in-memory corrections disappear when Studio stops.`
|
|
2264
|
-
: "Private local session."} Press Ctrl+C to stop.\n`,
|
|
2265
|
-
);
|
|
2266
|
-
if (environment.MOONDOG_STUDIO_NO_OPEN !== "1") {
|
|
2267
|
-
try {
|
|
2268
|
-
await launchBrowser(studio.url);
|
|
2269
|
-
} catch {
|
|
2270
|
-
stderr.write("Moondog could not open a browser automatically. Use the local URL above.\n");
|
|
2271
|
-
}
|
|
2272
|
-
}
|
|
2273
|
-
|
|
2274
|
-
let stop;
|
|
2275
|
-
const stopped = new Promise((resolve) => {
|
|
2276
|
-
stop = resolve;
|
|
2277
|
-
});
|
|
2278
|
-
const onSignal = () => stop();
|
|
2279
|
-
process.once("SIGINT", onSignal);
|
|
2280
|
-
process.once("SIGTERM", onSignal);
|
|
2281
|
-
try {
|
|
2282
|
-
await stopped;
|
|
2283
|
-
} finally {
|
|
2284
|
-
process.off("SIGINT", onSignal);
|
|
2285
|
-
process.off("SIGTERM", onSignal);
|
|
2286
|
-
await studio.close();
|
|
2287
|
-
}
|
|
2288
|
-
}
|