@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
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export function parseUpdateArguments(args = []) {
|
|
2
|
+
const result = { checkOnly: false };
|
|
3
|
+
for (let index = 0; index < args.length; index++) {
|
|
4
|
+
if (args[index] === "--check" && !result.checkOnly) result.checkOnly = true;
|
|
5
|
+
else if (args[index] === "--channel" && !result.channel && ["latest", "beta"].includes(args[index + 1])) result.channel = args[++index];
|
|
6
|
+
else if (["help", "--help", "-h"].includes(args[index]) && args.length === 1) return { help: true };
|
|
7
|
+
else throw new Error("Usage: moondog update [--check] [--channel latest|beta]");
|
|
8
|
+
}
|
|
9
|
+
return result;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function formatUpdateResult(result) {
|
|
13
|
+
if (result.state === "updated") return `Updated Moondog ${result.previous} → ${result.latest} (${result.channel}). Run moondog to start the new version.\nPrevious program: ${result.recoveryBackup}\nYour profile, sign-ins and conversation history were preserved.`;
|
|
14
|
+
if (result.state === "unavailable") return result.message;
|
|
15
|
+
if (result.state === "current") return `Moondog ${result.current} is current or newer than the ${result.channel} release (${result.latest}).`;
|
|
16
|
+
return `Moondog ${result.latest} is available (${result.channel}); installed: ${result.current}.\n${result.releaseUrl}\n${result.compatible ? "Run moondog update, or /update in the listening room." : `Update Node to ${result.minimumNode} or newer first.`}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function runUpdateCommand({ args = [], updater, signal, deferInstall = false, onProgress } = {}) {
|
|
20
|
+
const options = parseUpdateArguments(args);
|
|
21
|
+
if (options.help) return { text: "moondog update [--check] [--channel latest|beta]\n--check only reads release metadata. Updates preserve personal data and require other Moondog sessions to be closed." };
|
|
22
|
+
const result = options.checkOnly || deferInstall
|
|
23
|
+
? await updater.check({ channel: options.channel, force: true, signal })
|
|
24
|
+
: await updater.apply({ channel: options.channel, signal, onProgress });
|
|
25
|
+
if (options.checkOnly || deferInstall) signal?.throwIfAborted();
|
|
26
|
+
if (deferInstall && !options.checkOnly && result.state === "available" && result.compatible) {
|
|
27
|
+
const install = await updater.installation();
|
|
28
|
+
if (install.mode === "unmanaged") throw new Error("Update this installation using its original installation method.");
|
|
29
|
+
return { text: "Closing this session before installing the update. Your conversation is saved.", update: { channel: result.channel } };
|
|
30
|
+
}
|
|
31
|
+
return { text: formatUpdateResult(result), result };
|
|
32
|
+
}
|
|
@@ -1,44 +1,83 @@
|
|
|
1
|
+
import { translator } from "../../i18n/index.mjs";
|
|
2
|
+
import { webProviderName } from "../../integrations/web/web-result.mjs";
|
|
3
|
+
import { webSetupText } from "../../integrations/web/web-research.mjs";
|
|
1
4
|
import { sanitizeTerminalText } from "./format-output.mjs";
|
|
2
5
|
|
|
3
6
|
function inline(value) {
|
|
4
7
|
return sanitizeTerminalText(value).replace(/\s+/gu, " ").replace(/[\\`*_[\]<>]/gu, "\\$&");
|
|
5
8
|
}
|
|
6
9
|
|
|
7
|
-
|
|
10
|
+
function link(source) {
|
|
11
|
+
return `[${inline(source.title)}](<${source.url.replace(/[<>]/gu, encodeURIComponent)}>)`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The sources under an agent reply, in the reply's language, naming the services that found them. */
|
|
15
|
+
export function formatWebSources(sources, t = translator("en")) {
|
|
16
|
+
const providers = [...new Set(sources.map((source) => webProviderName(source.provider)).filter(Boolean))];
|
|
8
17
|
return [
|
|
9
|
-
|
|
18
|
+
`### ${providers.length ? t("web.sources.heading", { providers: providers.join(", ") }) : t("web.sources.headingUnnamed")}`,
|
|
10
19
|
"",
|
|
11
|
-
...sources.map((source) =>
|
|
20
|
+
...sources.map((source) => [
|
|
21
|
+
`- ${link(source)}`,
|
|
22
|
+
...(source.published_at ? [t("web.sources.published", { date: inline(source.published_at) })] : []),
|
|
23
|
+
...(source.retrieved_at ? [t("web.sources.retrieved", { date: source.retrieved_at.slice(0, 10) })] : []),
|
|
24
|
+
].join(" - ")),
|
|
12
25
|
].join("\n");
|
|
13
26
|
}
|
|
14
27
|
|
|
28
|
+
const contentNotes = {
|
|
29
|
+
model_generated_web_summary: () => "A Codex summary of public pages. The dates are when each page was published, which may not be when things happened.",
|
|
30
|
+
search_engine_results: (name) => `Search results from ${name}, as the search engine gives them. The dates are when each page was published, which may not be when things happened.`,
|
|
31
|
+
fetched_page_text: (name, result) => `The page's own text, fetched by ${name}${result.truncated ? " and cut short" : ""}.`,
|
|
32
|
+
};
|
|
33
|
+
|
|
15
34
|
export function formatWebResearch(result) {
|
|
35
|
+
const name = webProviderName(result.provider) ?? "the web service";
|
|
36
|
+
const snippet = (text) => text.length > 320 ? `${text.slice(0, 319).trimEnd()}…` : text;
|
|
16
37
|
return [
|
|
17
|
-
`# Web ${result.kind}: ${result.status}`,
|
|
38
|
+
`# Web ${result.kind}: ${result.status.replaceAll("_", " ")}`,
|
|
18
39
|
"",
|
|
19
40
|
`Retrieved: ${result.retrieved_at}${result.from_cache ? " (cached)" : ""}`,
|
|
20
|
-
|
|
21
|
-
"",
|
|
22
|
-
|
|
23
|
-
"",
|
|
24
|
-
"## Sources",
|
|
25
|
-
"",
|
|
41
|
+
contentNotes[result.content_kind]?.(name, result) ?? `From ${name}.`,
|
|
42
|
+
...(result.summary ? ["", sanitizeTerminalText(result.summary)] : []),
|
|
43
|
+
...(result.sources.length ? ["", "## Sources", ""] : []),
|
|
26
44
|
...result.sources.flatMap((source) => [
|
|
27
|
-
`-
|
|
28
|
-
` ${inline(source.snippet)}
|
|
45
|
+
`- ${link(source)}${source.published_at ? ` - source date: ${inline(source.published_at)}` : " - source date unavailable"}`,
|
|
46
|
+
...(source.snippet ? [` ${inline(snippet(source.snippet))}`] : []),
|
|
29
47
|
]),
|
|
30
48
|
].join("\n");
|
|
31
49
|
}
|
|
32
50
|
|
|
51
|
+
function formatStatus(status) {
|
|
52
|
+
const line = (label, entry) => {
|
|
53
|
+
const name = webProviderName(entry.provider);
|
|
54
|
+
if (entry.state === "configured") {
|
|
55
|
+
return `- ${label}: ${name}${entry.credential ? `, with the key from ${entry.credential === "stored credential" ? "/auth" : entry.credential}` : ""}${entry.selected_by ? `, chosen by ${entry.selected_by}` : ""}`;
|
|
56
|
+
}
|
|
57
|
+
const reason = entry.requested ? `${entry.requested} is not a web backend` : entry.reason?.replaceAll("_", " ") ?? "not set up";
|
|
58
|
+
return `- ${label}: not set up${name ? ` (${name}: ${reason})` : ` (${reason})`}`;
|
|
59
|
+
};
|
|
60
|
+
return [
|
|
61
|
+
"# Web lookups",
|
|
62
|
+
"",
|
|
63
|
+
// A single backend reports one status for both.
|
|
64
|
+
line("Search", status.search ?? status),
|
|
65
|
+
line("Page reading", status.read ?? status),
|
|
66
|
+
"",
|
|
67
|
+
...(status.state === "configured" ? [] : ["To set one up:", "", webSetupText, "", "From the shell, `moondog auth login deepseek` or `moondog auth login brave` does the same.", ""]),
|
|
68
|
+
"Checking this didn't search anything.",
|
|
69
|
+
].join("\n");
|
|
70
|
+
}
|
|
71
|
+
|
|
33
72
|
export async function runWebCommand({ args = [], webResearch, json = false, signal } = {}) {
|
|
34
73
|
const [action = "help", ...values] = args;
|
|
35
74
|
if (action === "help" || action === "--help") {
|
|
36
|
-
return
|
|
75
|
+
return `# Moondog public web\n\n- \`/web status\` - see which service searches and reads pages, without a request\n- \`/web search <query>\` - search reviews, news, interviews or concert information\n- \`/web read <https://public-page>\` - read a public page\n\nSearch uses DeepSeek when \`/auth deepseek\` has a key, then Brave Search with \`/auth brave\`, then your signed-in Codex CLI. Reading uses DeepSeek, then Codex. \`MOONDOG_WEB_SEARCH_PROVIDER\` and \`MOONDOG_WEB_READ_PROVIDER\` (deepseek, brave or codex) choose one.\n\nResults stay in the TUI. A 5-minute process-local cache avoids repeat requests. Ctrl+C cancels a request. No music-profile import is needed.\n\nShell equivalents: \`moondog web status|search|read [arguments] [--json]\`.`;
|
|
37
76
|
}
|
|
38
77
|
if (!webResearch) throw new Error("Web lookups aren't available right now.");
|
|
39
78
|
if (action === "status" && values.length === 0) {
|
|
40
79
|
const status = webResearch.publicStatus();
|
|
41
|
-
return json ? JSON.stringify(status, null, 2) :
|
|
80
|
+
return json ? JSON.stringify(status, null, 2) : formatStatus(status);
|
|
42
81
|
}
|
|
43
82
|
if (action === "search" && values.length > 0) {
|
|
44
83
|
const result = await webResearch.search({ query: values.join(" ") }, { signal });
|
|
@@ -26,6 +26,16 @@ function optionalNumber(value, { integer = false, minimum = 0 } = {}) {
|
|
|
26
26
|
return value;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
// The projection leaves out a measure that no play recorded: an absent value
|
|
30
|
+
// is unknown, while a present value must still be valid.
|
|
31
|
+
function recordedNumber(value, options) {
|
|
32
|
+
return value === undefined ? undefined : optionalNumber(value, options);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function exceeds(part, whole) {
|
|
36
|
+
return part !== undefined && whole !== undefined && part > whole;
|
|
37
|
+
}
|
|
38
|
+
|
|
29
39
|
function list(value, maximum, projector) {
|
|
30
40
|
if (!Array.isArray(value)) return [];
|
|
31
41
|
return value.slice(0, maximum).map(projector).filter(Boolean);
|
|
@@ -186,10 +196,10 @@ function listeningSeasonArtistSignal(item) {
|
|
|
186
196
|
integer: true,
|
|
187
197
|
minimum: 1,
|
|
188
198
|
});
|
|
189
|
-
const engagedPlayCount =
|
|
199
|
+
const engagedPlayCount = recordedNumber(item.engaged_play_count, {
|
|
190
200
|
integer: true,
|
|
191
201
|
});
|
|
192
|
-
const listeningMinutes =
|
|
202
|
+
const listeningMinutes = recordedNumber(item.listening_minutes);
|
|
193
203
|
const distinctTracks = optionalNumber(item.distinct_tracks, {
|
|
194
204
|
integer: true,
|
|
195
205
|
minimum: 1,
|
|
@@ -198,19 +208,19 @@ function listeningSeasonArtistSignal(item) {
|
|
|
198
208
|
!name ||
|
|
199
209
|
eventCount === null ||
|
|
200
210
|
engagedPlayCount === null ||
|
|
201
|
-
engagedPlayCount
|
|
211
|
+
exceeds(engagedPlayCount, eventCount) ||
|
|
202
212
|
listeningMinutes === null ||
|
|
203
213
|
distinctTracks === null
|
|
204
214
|
) {
|
|
205
215
|
return null;
|
|
206
216
|
}
|
|
207
|
-
return {
|
|
217
|
+
return compactObject({
|
|
208
218
|
name,
|
|
209
219
|
event_count: eventCount,
|
|
210
220
|
engaged_play_count: engagedPlayCount,
|
|
211
221
|
listening_minutes: listeningMinutes,
|
|
212
222
|
distinct_tracks: distinctTracks,
|
|
213
|
-
};
|
|
223
|
+
});
|
|
214
224
|
}
|
|
215
225
|
|
|
216
226
|
function listeningSeasonTrackSignal(item) {
|
|
@@ -221,11 +231,11 @@ function listeningSeasonTrackSignal(item) {
|
|
|
221
231
|
integer: true,
|
|
222
232
|
minimum: 1,
|
|
223
233
|
});
|
|
224
|
-
const engagedPlayCount =
|
|
234
|
+
const engagedPlayCount = recordedNumber(item.engaged_play_count, {
|
|
225
235
|
integer: true,
|
|
226
236
|
});
|
|
227
|
-
const listeningMinutes =
|
|
228
|
-
const explicitSkips =
|
|
237
|
+
const listeningMinutes = recordedNumber(item.listening_minutes);
|
|
238
|
+
const explicitSkips = recordedNumber(item.explicit_skips, {
|
|
229
239
|
integer: true,
|
|
230
240
|
});
|
|
231
241
|
if (
|
|
@@ -233,10 +243,12 @@ function listeningSeasonTrackSignal(item) {
|
|
|
233
243
|
!artistCredit ||
|
|
234
244
|
playCount === null ||
|
|
235
245
|
engagedPlayCount === null ||
|
|
236
|
-
engagedPlayCount
|
|
246
|
+
exceeds(engagedPlayCount, playCount) ||
|
|
237
247
|
listeningMinutes === null ||
|
|
238
248
|
explicitSkips === null ||
|
|
239
|
-
engagedPlayCount
|
|
249
|
+
(engagedPlayCount === undefined) !== (explicitSkips === undefined) ||
|
|
250
|
+
(engagedPlayCount !== undefined &&
|
|
251
|
+
engagedPlayCount + explicitSkips !== playCount)
|
|
240
252
|
) {
|
|
241
253
|
return null;
|
|
242
254
|
}
|
|
@@ -267,10 +279,10 @@ function listeningSeasonSignal(item) {
|
|
|
267
279
|
integer: true,
|
|
268
280
|
});
|
|
269
281
|
const eventCount = optionalNumber(item.event_count, { integer: true });
|
|
270
|
-
const engagedPlayCount =
|
|
282
|
+
const engagedPlayCount = recordedNumber(item.engaged_play_count, {
|
|
271
283
|
integer: true,
|
|
272
284
|
});
|
|
273
|
-
const listeningMinutes =
|
|
285
|
+
const listeningMinutes = recordedNumber(item.listening_minutes);
|
|
274
286
|
const distinctTracks = optionalNumber(item.distinct_tracks, {
|
|
275
287
|
integer: true,
|
|
276
288
|
});
|
|
@@ -296,7 +308,7 @@ function listeningSeasonSignal(item) {
|
|
|
296
308
|
activeMonthCount > retainedMonthCount ||
|
|
297
309
|
eventCount === null ||
|
|
298
310
|
engagedPlayCount === null ||
|
|
299
|
-
engagedPlayCount
|
|
311
|
+
exceeds(engagedPlayCount, eventCount) ||
|
|
300
312
|
listeningMinutes === null ||
|
|
301
313
|
distinctTracks === null ||
|
|
302
314
|
distinctTracks > eventCount ||
|
|
@@ -305,8 +317,8 @@ function listeningSeasonSignal(item) {
|
|
|
305
317
|
firstObservedTracks + returningTracks !== distinctTracks ||
|
|
306
318
|
(eventCount === 0 &&
|
|
307
319
|
(activeMonthCount !== 0 ||
|
|
308
|
-
engagedPlayCount !== 0 ||
|
|
309
|
-
listeningMinutes !== 0 ||
|
|
320
|
+
(engagedPlayCount ?? 0) !== 0 ||
|
|
321
|
+
(listeningMinutes ?? 0) !== 0 ||
|
|
310
322
|
distinctTracks !== 0 ||
|
|
311
323
|
leadingArtist !== null ||
|
|
312
324
|
signatureTrack !== null)) ||
|
|
@@ -317,13 +329,13 @@ function listeningSeasonSignal(item) {
|
|
|
317
329
|
signatureTrack === null)) ||
|
|
318
330
|
(leadingArtist &&
|
|
319
331
|
(leadingArtist.event_count > eventCount ||
|
|
320
|
-
leadingArtist.engaged_play_count
|
|
321
|
-
leadingArtist.listening_minutes
|
|
332
|
+
exceeds(leadingArtist.engaged_play_count, engagedPlayCount) ||
|
|
333
|
+
exceeds(leadingArtist.listening_minutes, listeningMinutes) ||
|
|
322
334
|
leadingArtist.distinct_tracks > distinctTracks)) ||
|
|
323
335
|
(signatureTrack &&
|
|
324
336
|
(signatureTrack.play_count > eventCount ||
|
|
325
|
-
signatureTrack.engaged_play_count
|
|
326
|
-
signatureTrack.listening_minutes
|
|
337
|
+
exceeds(signatureTrack.engaged_play_count, engagedPlayCount) ||
|
|
338
|
+
exceeds(signatureTrack.listening_minutes, listeningMinutes)))
|
|
327
339
|
) {
|
|
328
340
|
return null;
|
|
329
341
|
}
|
|
@@ -814,7 +826,6 @@ export function createTasteprintView(
|
|
|
814
826
|
profile,
|
|
815
827
|
{
|
|
816
828
|
generatedAt = new Date().toISOString(),
|
|
817
|
-
syntheticDemo = false,
|
|
818
829
|
} = {},
|
|
819
830
|
) {
|
|
820
831
|
if (!isPlainObject(profile)) {
|
|
@@ -823,9 +834,6 @@ export function createTasteprintView(
|
|
|
823
834
|
if (typeof generatedAt !== "string" || Number.isNaN(Date.parse(generatedAt))) {
|
|
824
835
|
throw new TypeError("Tasteprint generatedAt must be an ISO timestamp.");
|
|
825
836
|
}
|
|
826
|
-
if (typeof syntheticDemo !== "boolean") {
|
|
827
|
-
throw new TypeError("Tasteprint syntheticDemo must be a boolean.");
|
|
828
|
-
}
|
|
829
837
|
const coverage = isPlainObject(profile.coverage) ? profile.coverage : {};
|
|
830
838
|
const behavior = isPlainObject(profile.listening_behavior)
|
|
831
839
|
? profile.listening_behavior
|
|
@@ -860,12 +868,9 @@ export function createTasteprintView(
|
|
|
860
868
|
return {
|
|
861
869
|
tasteprint_version: "moondog-tasteprint/1",
|
|
862
870
|
generated_at: new Date(generatedAt).toISOString(),
|
|
863
|
-
synthetic_demo: syntheticDemo,
|
|
864
871
|
privacy: {
|
|
865
|
-
classification:
|
|
866
|
-
|
|
867
|
-
: "private_local_tasteprint",
|
|
868
|
-
contains_personal_music_context: !syntheticDemo,
|
|
872
|
+
classification: "private_local_tasteprint",
|
|
873
|
+
contains_personal_music_context: true,
|
|
869
874
|
network_requests: "none",
|
|
870
875
|
},
|
|
871
876
|
profile_version: cleanText(profile.profile_version, 80) || "unknown",
|
|
@@ -1830,7 +1835,6 @@ function crossFormatIdentityCoverage(coverage) {
|
|
|
1830
1835
|
|
|
1831
1836
|
export function renderTasteprintHtml(profile, options = {}) {
|
|
1832
1837
|
const view = createTasteprintView(profile, options);
|
|
1833
|
-
const syntheticDemo = view.synthetic_demo === true;
|
|
1834
1838
|
const coverage = view.coverage;
|
|
1835
1839
|
const context = view.behavior.context;
|
|
1836
1840
|
const profiledEvents =
|
|
@@ -2000,7 +2004,7 @@ export function renderTasteprintHtml(profile, options = {}) {
|
|
|
2000
2004
|
<meta name="robots" content="noindex, nofollow, noarchive">
|
|
2001
2005
|
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; connect-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'">
|
|
2002
2006
|
<meta name="color-scheme" content="light">
|
|
2003
|
-
<title
|
|
2007
|
+
<title>Private Moondog Tasteprint</title>
|
|
2004
2008
|
<style>
|
|
2005
2009
|
:root {
|
|
2006
2010
|
--paper: #eee8de;
|
|
@@ -2302,20 +2306,6 @@ export function renderTasteprintHtml(profile, options = {}) {
|
|
|
2302
2306
|
|
|
2303
2307
|
.section.dark { color: #f5f1e9; background: var(--night); border-color: #10100f; }
|
|
2304
2308
|
|
|
2305
|
-
.demo-chip {
|
|
2306
|
-
display: inline-flex;
|
|
2307
|
-
margin: 0 0 22px;
|
|
2308
|
-
padding: 7px 10px;
|
|
2309
|
-
border: 1px solid #5f7182;
|
|
2310
|
-
border-radius: 999px;
|
|
2311
|
-
color: #c8d9e8;
|
|
2312
|
-
background: rgba(111, 140, 168, 0.12);
|
|
2313
|
-
font-size: 9px;
|
|
2314
|
-
font-weight: 850;
|
|
2315
|
-
letter-spacing: 0.14em;
|
|
2316
|
-
text-transform: uppercase;
|
|
2317
|
-
}
|
|
2318
|
-
|
|
2319
2309
|
.section-heading {
|
|
2320
2310
|
display: grid;
|
|
2321
2311
|
grid-template-columns: minmax(0, 1fr) minmax(220px, 360px);
|
|
@@ -2490,20 +2480,6 @@ export function renderTasteprintHtml(profile, options = {}) {
|
|
|
2490
2480
|
rgba(248, 244, 237, 0.94);
|
|
2491
2481
|
}
|
|
2492
2482
|
|
|
2493
|
-
.pattern-demo-chip {
|
|
2494
|
-
display: inline-flex;
|
|
2495
|
-
margin: 0 0 22px;
|
|
2496
|
-
padding: 7px 10px;
|
|
2497
|
-
border: 1px solid #a8bac9;
|
|
2498
|
-
border-radius: 999px;
|
|
2499
|
-
color: #4f708f;
|
|
2500
|
-
background: rgba(237, 243, 247, 0.86);
|
|
2501
|
-
font-size: 9px;
|
|
2502
|
-
font-weight: 850;
|
|
2503
|
-
letter-spacing: 0.14em;
|
|
2504
|
-
text-transform: uppercase;
|
|
2505
|
-
}
|
|
2506
|
-
|
|
2507
2483
|
.pattern-grid {
|
|
2508
2484
|
align-items: start;
|
|
2509
2485
|
grid-template-columns: repeat(auto-fit, minmax(min(380px, 100%), 1fr));
|
|
@@ -2981,7 +2957,7 @@ export function renderTasteprintHtml(profile, options = {}) {
|
|
|
2981
2957
|
<body>
|
|
2982
2958
|
<main class="shell">
|
|
2983
2959
|
<header class="hero">
|
|
2984
|
-
<p class="eyebrow"
|
|
2960
|
+
<p class="eyebrow">Moondog listening report, private</p>
|
|
2985
2961
|
<h1>${escapeHtml(spanLabel(view.timeline.days))}, seen clearly.</h1>
|
|
2986
2962
|
<p class="hero-copy">${escapeHtml(heroCopy)}</p>
|
|
2987
2963
|
<div class="hero-meta" aria-label="Tasteprint snapshot metadata">
|
|
@@ -2991,9 +2967,9 @@ export function renderTasteprintHtml(profile, options = {}) {
|
|
|
2991
2967
|
</div>
|
|
2992
2968
|
</header>
|
|
2993
2969
|
|
|
2994
|
-
<aside class="privacy" aria-label="
|
|
2970
|
+
<aside class="privacy" aria-label="Privacy boundary">
|
|
2995
2971
|
<span class="privacy-mark" aria-hidden="true">L</span>
|
|
2996
|
-
<div><strong
|
|
2972
|
+
<div><strong>Private, made on this machine</strong><p>This page is about you. It loads nothing from the internet. Share it only if you are happy for others to see the artists, tracks, and numbers on it.</p></div>
|
|
2997
2973
|
</aside>
|
|
2998
2974
|
|
|
2999
2975
|
<section class="overview" aria-label="Listening coverage">
|
|
@@ -3008,7 +2984,6 @@ export function renderTasteprintHtml(profile, options = {}) {
|
|
|
3008
2984
|
${sectionNavigator}
|
|
3009
2985
|
|
|
3010
2986
|
<section class="section dark" id="taste-shape">
|
|
3011
|
-
${syntheticDemo ? '<p class="demo-chip">Fictional public profile</p>' : ""}
|
|
3012
2987
|
<div class="section-heading"><h2>Shine On</h2><p>The artists who stayed with you across the years, and the ones you have played in the last ${escapeHtml(number(recentWindow))} days. Bars compare within each list only.</p></div>
|
|
3013
2988
|
<div class="two-column">
|
|
3014
2989
|
<article class="panel"><span class="panel-label">All of your history</span><h3>Across the years</h3>${artistRows(view.behavior.enduring_artists)}</article>
|
|
@@ -3033,7 +3008,6 @@ export function renderTasteprintHtml(profile, options = {}) {
|
|
|
3033
3008
|
</section>` : ""}
|
|
3034
3009
|
|
|
3035
3010
|
${hasListeningPatterns ? `<section class="section pattern-section" id="listening-patterns">
|
|
3036
|
-
${syntheticDemo ? '<p class="pattern-demo-chip">Fictional archive preview</p>' : ""}
|
|
3037
3011
|
<div class="section-heading"><h2>Inside a listening stretch</h2><p>Spotify's extended history shows how plays cluster, which songs you played again right away, and which records you went deep on. These are patterns, not a read on your mood, routine, or taste.</p></div>
|
|
3038
3012
|
<div class="two-column pattern-grid">
|
|
3039
3013
|
${view.behavior.session_summary ? `<article class="panel session-panel"><span class="panel-label">${escapeHtml(number(view.behavior.session_summary.gap_minutes))}-minute breaks</span><h3>Listening stretches</h3><p class="panel-intro">Plays are grouped together until a longer break starts a new stretch.</p>${sessionShape(view.behavior.session_summary)}</article>` : ""}
|
|
@@ -3082,7 +3056,7 @@ export function renderTasteprintHtml(profile, options = {}) {
|
|
|
3082
3056
|
<div class="limitations">${view.limitations.map((item) => `<p>${escapeHtml(item)}</p>`).join("")}</div>
|
|
3083
3057
|
</section>
|
|
3084
3058
|
|
|
3085
|
-
<footer><span>Moondog listening report /
|
|
3059
|
+
<footer><span>Moondog listening report / private, made on this machine / works offline</span><span>Generated ${escapeHtml(view.generated_at)}</span></footer>
|
|
3086
3060
|
</main>
|
|
3087
3061
|
</body>
|
|
3088
3062
|
</html>
|
|
@@ -3091,7 +3065,6 @@ export function renderTasteprintHtml(profile, options = {}) {
|
|
|
3091
3065
|
|
|
3092
3066
|
export function renderTasteprintCardHtml(profile, options = {}) {
|
|
3093
3067
|
const view = createTasteprintView(profile, options);
|
|
3094
|
-
const syntheticDemo = view.synthetic_demo === true;
|
|
3095
3068
|
const coverage = view.coverage;
|
|
3096
3069
|
const recentWindow = view.behavior.context.recent_window_days ?? 90;
|
|
3097
3070
|
const enduringArtists = view.behavior.enduring_artists.slice(0, 3);
|
|
@@ -3156,7 +3129,7 @@ export function renderTasteprintCardHtml(profile, options = {}) {
|
|
|
3156
3129
|
<meta name="robots" content="noindex, nofollow, noarchive">
|
|
3157
3130
|
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; connect-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'">
|
|
3158
3131
|
<meta name="color-scheme" content="light">
|
|
3159
|
-
<title
|
|
3132
|
+
<title>Private Moondog Tasteprint Card</title>
|
|
3160
3133
|
<style>
|
|
3161
3134
|
:root {
|
|
3162
3135
|
color: #1b1b19;
|
|
@@ -3520,7 +3493,7 @@ export function renderTasteprintCardHtml(profile, options = {}) {
|
|
|
3520
3493
|
<body>
|
|
3521
3494
|
<main class="taste-card" data-artifact="moondog-tasteprint-card/1">
|
|
3522
3495
|
<section class="identity" aria-labelledby="card-title">
|
|
3523
|
-
<div class="brand-row"><span>Moondog</span><span
|
|
3496
|
+
<div class="brand-row"><span>Moondog</span><span>Private recap</span></div>
|
|
3524
3497
|
<p class="card-kicker">Tasteprint card / across the years</p>
|
|
3525
3498
|
<h1 id="card-title">${escapeHtml(headline)}</h1>
|
|
3526
3499
|
<p class="span">${escapeHtml(spanLabel(view.timeline.days))}, read from your own listening history.</p>
|
|
@@ -3533,7 +3506,7 @@ export function renderTasteprintCardHtml(profile, options = {}) {
|
|
|
3533
3506
|
|
|
3534
3507
|
<section class="content" aria-label="Tasteprint recap">
|
|
3535
3508
|
<header class="content-header">
|
|
3536
|
-
<p class="content-title"
|
|
3509
|
+
<p class="content-title">Private listening recap</p>
|
|
3537
3510
|
<p class="content-range">${escapeHtml(rangeLabel)}</p>
|
|
3538
3511
|
</header>
|
|
3539
3512
|
|
|
@@ -3559,8 +3532,8 @@ export function renderTasteprintCardHtml(profile, options = {}) {
|
|
|
3559
3532
|
<aside class="boundary" aria-label="Interpretation and privacy boundary">
|
|
3560
3533
|
<span class="boundary-mark" aria-hidden="true">L</span>
|
|
3561
3534
|
<div>
|
|
3562
|
-
<strong
|
|
3563
|
-
<p
|
|
3535
|
+
<strong>Read it before you share it</strong>
|
|
3536
|
+
<p>This card is about you. Check every artist, track, date, and number on it before you share it.</p>
|
|
3564
3537
|
<p>${escapeHtml(boundary)}</p>
|
|
3565
3538
|
${correctionCount > 0 ? `<span class="direct-signal">${escapeHtml(number(correctionCount))} of your own ${correctionCount === 1 ? "choice" : "choices"} applied</span>` : ""}
|
|
3566
3539
|
</div>
|