@audiofool/moondog 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/README.md +125 -0
  2. package/assets/brand/moondog-lunar-record/moondog-logo-1x1.png +0 -0
  3. package/contracts/v1/capability-policy.schema.json +572 -0
  4. package/contracts/v1/definitions.schema.json +365 -0
  5. package/contracts/v1/library-track-observation.schema.json +138 -0
  6. package/contracts/v1/listening-event.schema.json +111 -0
  7. package/contracts/v1/profile-evidence.schema.json +237 -0
  8. package/contracts/v1/taste-event.schema.json +258 -0
  9. package/contracts/v1/tool-invocation.schema.json +318 -0
  10. package/contracts/v1/tool-receipt.schema.json +1066 -0
  11. package/contracts/v1/track-ref.schema.json +275 -0
  12. package/contracts/v2/profile-evidence.schema.json +284 -0
  13. package/package.json +105 -0
  14. package/scripts/contract-lib.mjs +361 -0
  15. package/scripts/contract-semantics.mjs +2630 -0
  16. package/scripts/demo-studio.mjs +58 -0
  17. package/scripts/moondog +52 -0
  18. package/scripts/moondog.mjs +954 -0
  19. package/src/core/apple-data-migration.mjs +50 -0
  20. package/src/core/apple-library-source-status.mjs +220 -0
  21. package/src/core/apple-projection-domain-services.mjs +2356 -0
  22. package/src/core/capability-catalog.mjs +577 -0
  23. package/src/core/listening-profile-domain-services.mjs +1010 -0
  24. package/src/core/lyric-library.mjs +121 -0
  25. package/src/core/lyric-profile.mjs +50 -0
  26. package/src/core/lyric-service.mjs +41 -0
  27. package/src/core/moondog-application.mjs +1827 -0
  28. package/src/core/state-directory.mjs +19 -0
  29. package/src/core/synthetic-domain-services.mjs +1485 -0
  30. package/src/core/uuid-v5.mjs +42 -0
  31. package/src/demo/fictional-spotify-history.mjs +400 -0
  32. package/src/demo/moondog-demo.mjs +355 -0
  33. package/src/demo/moondog-interactive-tasteprint-demo.mjs +293 -0
  34. package/src/demo/moondog-tasteprint-demo.mjs +631 -0
  35. package/src/importers/apple-music-library/import-batch.mjs +290 -0
  36. package/src/importers/apple-music-library/index.mjs +76 -0
  37. package/src/importers/apple-music-library/normalize.mjs +1126 -0
  38. package/src/importers/apple-music-library/parse-plist.mjs +357 -0
  39. package/src/importers/apple-music-library/projection-records.mjs +557 -0
  40. package/src/importers/apple-music-library/read-batch.mjs +276 -0
  41. package/src/importers/apple-music-library/sqlite-projection.mjs +1307 -0
  42. package/src/importers/apple-music-library/stable-ids.mjs +19 -0
  43. package/src/importers/apple-music-library/track-snapshot.schema.json +136 -0
  44. package/src/integrations/apple-music/catalog.mjs +770 -0
  45. package/src/integrations/cross-catalog-artist-identity.mjs +222 -0
  46. package/src/integrations/listenbrainz/history-file.mjs +560 -0
  47. package/src/integrations/lyrics/lrclib.mjs +55 -0
  48. package/src/integrations/open-music-similarity/artist-radio.mjs +806 -0
  49. package/src/integrations/public-playlists.mjs +104 -0
  50. package/src/integrations/spotify/account-data-history.mjs +545 -0
  51. package/src/integrations/spotify/account-data-profile.mjs +918 -0
  52. package/src/integrations/spotify/authentication.mjs +791 -0
  53. package/src/integrations/spotify/catalog-resolver.mjs +657 -0
  54. package/src/integrations/spotify/connection.mjs +125 -0
  55. package/src/integrations/spotify/credential-store.mjs +26 -0
  56. package/src/integrations/spotify/extended-streaming-history.mjs +525 -0
  57. package/src/integrations/spotify/history-archive.mjs +59 -0
  58. package/src/integrations/spotify/index.mjs +80 -0
  59. package/src/integrations/spotify/recent-activity.mjs +198 -0
  60. package/src/integrations/spotify/resolution-cache.mjs +260 -0
  61. package/src/integrations/spotify/service.mjs +713 -0
  62. package/src/integrations/spotify/settings.mjs +89 -0
  63. package/src/integrations/spotify/web-api-client.mjs +761 -0
  64. package/src/integrations/web/codex-web.mjs +263 -0
  65. package/src/integrations/youtube-music/takeout.mjs +143 -0
  66. package/src/memory/local-memory-store.mjs +1705 -0
  67. package/src/memory/reflection-worker.mjs +87 -0
  68. package/src/profile/apple-library-import.mjs +28 -0
  69. package/src/profile/history-import.mjs +163 -0
  70. package/src/profile/listener-corrections.mjs +217 -0
  71. package/src/profile/listening-history-store.mjs +2055 -0
  72. package/src/profile/listening-profile-projection.mjs +2520 -0
  73. package/src/profile/local-music-data.mjs +812 -0
  74. package/src/profile/music-import-bundle.mjs +91 -0
  75. package/src/profile/music-providers.mjs +25 -0
  76. package/src/profile/spotify-archive-catalog-hints.mjs +210 -0
  77. package/src/profile/spotify-archive-taste.mjs +482 -0
  78. package/src/runtime/pi/agent-runtime.mjs +6444 -0
  79. package/src/runtime/pi/authentication.mjs +104 -0
  80. package/src/runtime/pi/configured-runtime.mjs +98 -0
  81. package/src/runtime/pi/memory-agent-runtime.mjs +337 -0
  82. package/src/runtime/pi/model-catalog.mjs +62 -0
  83. package/src/runtime/pi/model-transport.mjs +117 -0
  84. package/src/runtime/pi/persistent-credential-store.mjs +670 -0
  85. package/src/runtime/pi/provider-registry.mjs +74 -0
  86. package/src/runtime/pi/runtime-settings.mjs +81 -0
  87. package/src/surfaces/cli/auth-command.mjs +355 -0
  88. package/src/surfaces/cli/brand-components.mjs +390 -0
  89. package/src/surfaces/cli/brand-theme.mjs +231 -0
  90. package/src/surfaces/cli/catalog-command.mjs +509 -0
  91. package/src/surfaces/cli/command-completions.mjs +113 -0
  92. package/src/surfaces/cli/data-command.mjs +214 -0
  93. package/src/surfaces/cli/format-output.mjs +750 -0
  94. package/src/surfaces/cli/history-import-view.mjs +445 -0
  95. package/src/surfaces/cli/import-guides.mjs +9 -0
  96. package/src/surfaces/cli/listenbrainz-command.mjs +129 -0
  97. package/src/surfaces/cli/listening-menu.mjs +171 -0
  98. package/src/surfaces/cli/profile-command.mjs +290 -0
  99. package/src/surfaces/cli/spotify-command.mjs +849 -0
  100. package/src/surfaces/cli/taste-profile-model.mjs +306 -0
  101. package/src/surfaces/cli/taste-profile-view.mjs +272 -0
  102. package/src/surfaces/cli/terminal-art.mjs +295 -0
  103. package/src/surfaces/cli/tui.mjs +1714 -0
  104. package/src/surfaces/cli/web-command.mjs +52 -0
  105. package/src/surfaces/html/tasteprint-artifact.mjs +96 -0
  106. package/src/surfaces/html/tasteprint.mjs +3575 -0
  107. package/src/surfaces/web/showcase.mjs +272 -0
  108. package/src/surfaces/web/studio-page.mjs +4148 -0
  109. package/src/surfaces/web/studio.mjs +2288 -0
@@ -0,0 +1,87 @@
1
+ import {
2
+ createConfiguredMemoryAgentRuntime,
3
+ MAX_REFLECTION_EPISODES,
4
+ MEMORY_AGENT_WORKER_VERSION,
5
+ } from "../runtime/pi/memory-agent-runtime.mjs";
6
+
7
+ function reflectionErrorCode(error) {
8
+ const message = String(error?.message ?? "");
9
+ if (/did not submit|exact episode|structured reflection/iu.test(message)) {
10
+ return "model_output_invalid";
11
+ }
12
+ if (/model request failed|provider/iu.test(message)) return "model_failed";
13
+ return "reflection_failed";
14
+ }
15
+
16
+ function offlineRuntimeError(reason) {
17
+ if (reason === "provider_authentication_required") {
18
+ return new Error(
19
+ "The Memory Agent requires OpenAI Codex authentication. Run moondog auth login openai-codex first.",
20
+ );
21
+ }
22
+ return new Error(
23
+ "The Memory Agent model is offline. Choose a model with /model or configure MOONDOG_PROVIDER and MOONDOG_MODEL.",
24
+ );
25
+ }
26
+
27
+ export async function runMemoryReflection({
28
+ application,
29
+ environment = process.env,
30
+ trigger = "manual",
31
+ dryRun = false,
32
+ runtimeFactory = createConfiguredMemoryAgentRuntime,
33
+ limit = MAX_REFLECTION_EPISODES,
34
+ } = {}) {
35
+ if (!application) throw new TypeError("Memory reflection needs an application");
36
+
37
+ const pending = application.pendingMemoryReflection({ limit });
38
+ if (pending.length === 0) {
39
+ return {
40
+ state: "no_work",
41
+ examined_episodes: 0,
42
+ message: "No unprocessed memory episodes are ready for reflection.",
43
+ };
44
+ }
45
+
46
+ const runtime = await runtimeFactory(environment);
47
+ const runtimeStatus = runtime.publicStatus();
48
+ if (runtimeStatus.state !== "configured") {
49
+ throw offlineRuntimeError(runtimeStatus.reason);
50
+ }
51
+
52
+ const run = application.beginMemoryReflection({
53
+ trigger,
54
+ workerVersion: MEMORY_AGENT_WORKER_VERSION,
55
+ provider: runtimeStatus.provider,
56
+ model: runtimeStatus.model,
57
+ limit,
58
+ leaseMs: 15 * 60 * 1_000,
59
+ });
60
+ if (run.state !== "started") {
61
+ return {
62
+ state: run.state,
63
+ ...(run.active_run ? { active_run: run.active_run } : {}),
64
+ examined_episodes: 0,
65
+ };
66
+ }
67
+
68
+ try {
69
+ const reflected = await runtime.reflect({
70
+ episodes: run.episodes,
71
+ activeMemories: run.active_memories,
72
+ });
73
+ return application.completeMemoryReflection({
74
+ runId: run.run_id,
75
+ leaseToken: run.lease_token,
76
+ proposals: reflected.proposals,
77
+ dryRun,
78
+ });
79
+ } catch (error) {
80
+ application.failMemoryReflection(
81
+ run.run_id,
82
+ run.lease_token,
83
+ reflectionErrorCode(error),
84
+ );
85
+ throw error;
86
+ }
87
+ }
@@ -0,0 +1,28 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import { resolveAppleMusicImportsRoot } from "../core/apple-library-source-status.mjs";
5
+ import { resolveAppleMusicProjectionPath } from "../core/apple-projection-domain-services.mjs";
6
+ import { writeAppleMusicImportBatch, rebuildAppleMusicSqliteProjection } from "../importers/apple-music-library/index.mjs";
7
+
8
+ export async function persistAppleLibraryImport(bundle, { environment = process.env } = {}) {
9
+ const outputRoot = resolveAppleMusicImportsRoot(environment);
10
+ const boundaryRoot = path.dirname(outputRoot);
11
+ await mkdir(boundaryRoot, { recursive: true, mode: 0o700 });
12
+ const result = await writeAppleMusicImportBatch(bundle, { outputRoot, boundaryRoot });
13
+ return {
14
+ provider: "apple-music-library",
15
+ already_imported: result.status === "unchanged",
16
+ library_tracks: result.manifest.counts.track_refs,
17
+ };
18
+ }
19
+
20
+ export async function refreshAppleLibraryImport({ environment = process.env } = {}) {
21
+ const databasePath = resolveAppleMusicProjectionPath(environment);
22
+ // The existing writer requires its output to be below an existing boundary.
23
+ const boundaryRoot = path.dirname(path.dirname(databasePath));
24
+ await mkdir(boundaryRoot, { recursive: true, mode: 0o700 });
25
+ return rebuildAppleMusicSqliteProjection({
26
+ importsRoot: resolveAppleMusicImportsRoot(environment), databasePath, boundaryRoot,
27
+ });
28
+ }
@@ -0,0 +1,163 @@
1
+ import { lstat } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ import { readSpotifyHistoryArchive } from "../integrations/spotify/history-archive.mjs";
7
+ import { readListenBrainzHistoryFile } from "../integrations/listenbrainz/history-file.mjs";
8
+ import { projectSpotifyRecentActivity } from "../integrations/spotify/recent-activity.mjs";
9
+ import { buildAppleMusicLibraryImport } from "../importers/apple-music-library/index.mjs";
10
+ import { readYouTubeMusicTakeout } from "../integrations/youtube-music/takeout.mjs";
11
+ import { readPublicPlaylist } from "../integrations/public-playlists.mjs";
12
+
13
+ export function prepareSpotifyRecentImport({ page, subjectId, capturedAt = new Date().toISOString() }) {
14
+ const bundle = projectSpotifyRecentActivity({ page, subjectId, capturedAt });
15
+ const dates = bundle.listening_events.map((event) => event.occurred_at).sort();
16
+ return {
17
+ provider: "spotify-recent",
18
+ bundle,
19
+ preview: {
20
+ sourceLabel: "Spotify recent listening",
21
+ listeningEvents: bundle.listening_events.length,
22
+ tracks: bundle.track_refs.length,
23
+ earliestListeningAt: dates[0],
24
+ latestListeningAt: dates.at(-1),
25
+ eventsWithPlayedMs: 0,
26
+ scopeNote: "Your latest plays, up to 50. Spotify doesn't say how long you listened here. A history ZIP can add the older ones later.",
27
+ },
28
+ };
29
+ }
30
+
31
+ function fail(code, message) {
32
+ const error = new Error(message);
33
+ error.code = code;
34
+ throw error;
35
+ }
36
+
37
+ function literalPath(input) {
38
+ if (typeof input !== "string" || !input.trim()) {
39
+ fail("history_import_path_required", "Paste one music export file path or a QQ Music / NetEase playlist share link.");
40
+ }
41
+ let value = input.trim();
42
+ if (/[\u0000-\u001f\u007f]/u.test(value)) {
43
+ fail("history_import_path_invalid", "Paste one file path on a single line.");
44
+ }
45
+ const quote = ["'", '"'].includes(value[0]) ? value[0] : null;
46
+ if (quote) {
47
+ if (value.at(-1) !== quote) {
48
+ fail("history_import_path_invalid", "The quoted path is incomplete. Paste the complete file path.");
49
+ }
50
+ value = value.slice(1, -1);
51
+ }
52
+ let decoded = "";
53
+ for (let index = 0; index < value.length; index += 1) {
54
+ const character = value[index];
55
+ if (quote && character === quote) {
56
+ fail("history_import_path_invalid", "Paste one complete file path, not several quoted arguments.");
57
+ }
58
+ if (character === "\\" && quote !== "'") {
59
+ const next = value[index + 1];
60
+ const allowed = quote === '"' ? /["\\$`]/u : /[ \t()\[\]{}!&;'"\\#$~`]/u;
61
+ if (!next || !allowed.test(next)) {
62
+ fail("history_import_path_invalid", "The path has an unsupported escape. Paste the plain path, including its spaces.");
63
+ }
64
+ decoded += next;
65
+ index += 1;
66
+ } else {
67
+ decoded += character;
68
+ }
69
+ }
70
+ if (!decoded) fail("history_import_path_required", "Paste a file path between the quotes.");
71
+ if (/^file:/iu.test(decoded)) {
72
+ try {
73
+ const url = new URL(decoded);
74
+ if (url.hostname && url.hostname !== "localhost") throw new Error();
75
+ if (url.search || url.hash || url.username || url.password || url.port) throw new Error();
76
+ decoded = fileURLToPath(url);
77
+ } catch {
78
+ fail("history_import_path_invalid", "Use a local file:// URL with no remote host, query, or fragment, or paste the file path.");
79
+ }
80
+ } else if (/^[a-z][a-z0-9+.-]*:\/\//iu.test(decoded)) {
81
+ fail("history_import_path_invalid", "Choose a downloaded local file. Remote URLs are not imported.");
82
+ }
83
+ if (decoded === "~" || decoded.startsWith("~/")) decoded = path.join(homedir(), decoded.slice(1));
84
+ else if (decoded.startsWith("~")) fail("history_import_path_invalid", "Use ~/ for your home folder, or paste the full file path.");
85
+ return path.resolve(decoded);
86
+ }
87
+
88
+ export async function prepareHistoryImport({ filePath, subjectId, provider: selectedProvider, capturedAt = new Date().toISOString(), fetchImpl } = {}) {
89
+ if (/https?:\/\//iu.test(filePath ?? "") && !String(filePath).startsWith("file:")) {
90
+ return readPublicPlaylist({ input: filePath, subjectId, capturedAt, provider: selectedProvider, fetchImpl });
91
+ }
92
+ const resolvedPath = literalPath(filePath);
93
+ let file;
94
+ try {
95
+ file = await lstat(resolvedPath);
96
+ } catch (error) {
97
+ if (error.code === "ENOENT" || error.code === "ENOTDIR") {
98
+ fail("history_import_file_missing", `File not found: ${resolvedPath}. Paste the path to the downloaded file.`);
99
+ }
100
+ fail("history_import_file_unreadable", `Could not read ${resolvedPath}. Check that the file is accessible.`);
101
+ }
102
+ if (file.isDirectory()) {
103
+ fail("history_import_directory", "This is a folder. Choose the downloaded music export file inside it, or the original ZIP.");
104
+ }
105
+ if (!file.isFile()) fail("history_import_file_invalid", "Choose a regular music export file.");
106
+ const extension = path.extname(resolvedPath).toLowerCase();
107
+ if (selectedProvider === "youtube_music" || extension === ".csv" || /^(?:watch-history|takeout)[-.]/iu.test(path.basename(resolvedPath))) {
108
+ if (![".csv", ".json", ".zip"].includes(extension)) fail("history_import_format_unsupported", "Choose your Takeout ZIP, music-library-songs.csv or watch-history.json. For history, select JSON in Takeout instead of HTML.");
109
+ return readYouTubeMusicTakeout({ filePath: resolvedPath, subjectId, capturedAt });
110
+ }
111
+ if (extension === ".xml") {
112
+ const bundle = await buildAppleMusicLibraryImport(resolvedPath, { subjectId });
113
+ return {
114
+ provider: "apple-music-library",
115
+ bundle,
116
+ preview: {
117
+ kind: "library",
118
+ sourceLabel: "Apple Music library",
119
+ fileName: path.basename(resolvedPath),
120
+ tracks: bundle.trackRefs.length,
121
+ capturedAt: bundle.manifest.source.captured_at,
122
+ scopeNote: "Your library with loves, ratings, play counts and when you last played each song. It doesn't list every play.",
123
+ },
124
+ };
125
+ }
126
+ if (![".zip", ".json"].includes(extension)) {
127
+ fail("history_import_format_unsupported", "This format is not supported. Choose a Spotify history ZIP, Apple Music library XML, YouTube Music Takeout ZIP/CSV/JSON or saved ListenBrainz JSON.");
128
+ }
129
+ const provider = extension === ".zip" ? "spotify" : "listenbrainz";
130
+ let bundle;
131
+ try {
132
+ bundle = provider === "spotify"
133
+ ? await readSpotifyHistoryArchive({ archivePath: resolvedPath, subjectId, capturedAt })
134
+ : await readListenBrainzHistoryFile({ filePath: resolvedPath, subjectId, capturedAt });
135
+ } catch (error) {
136
+ if (provider === "listenbrainz" && error.code === "listenbrainz_history_invalid") {
137
+ error.message += " If this is a Spotify export, choose the original ZIP instead of an extracted JSON file.";
138
+ }
139
+ throw error;
140
+ }
141
+ const extended = bundle.import_batch.data_scope === "lifetime_extended_streaming_history";
142
+ return {
143
+ provider,
144
+ bundle,
145
+ preview: {
146
+ sourceLabel: provider === "listenbrainz" ? "ListenBrainz history" : extended ? "Spotify Extended Streaming History" : "Spotify Account Data",
147
+ fileName: path.basename(resolvedPath),
148
+ sourceFormat: bundle.import_batch.source_format,
149
+ dataScope: bundle.import_batch.data_scope,
150
+ listeningEvents: bundle.listening_events.length,
151
+ tracks: bundle.track_refs.length,
152
+ earliestListeningAt: bundle.import_batch.earliest_occurred_at,
153
+ latestListeningAt: bundle.import_batch.latest_occurred_at,
154
+ eventsWithPlayedMs: bundle.listening_events.filter((event) => Number.isSafeInteger(event.played_ms)).length,
155
+ profileEvidence: bundle.profile_evidence?.length ?? 0,
156
+ scopeNote: provider === "listenbrainz"
157
+ ? "Your saved listens. Some don't say how long you listened, so they count as plays without minutes."
158
+ : extended
159
+ ? "Every play in this file, with how long you listened and how each song started and ended."
160
+ : "Your plays from the past year, plus your library and playlists. For older plays and listening time, use Extended streaming history.",
161
+ },
162
+ };
163
+ }
@@ -0,0 +1,217 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ import { isUuid, uuidV5 } from "../core/uuid-v5.mjs";
4
+
5
+ export const LISTENER_CORRECTION_SCHEMA_VERSION = 1;
6
+ export const LISTENER_CORRECTION_ENTITY_NAMESPACE =
7
+ "6bcb265e-ce6d-5ab9-8471-7d6b40c95ba6";
8
+ export const LISTENER_CORRECTION_ENTITY_TYPES = Object.freeze([
9
+ "artist",
10
+ "track",
11
+ ]);
12
+ export const LISTENER_CORRECTION_STANCES = Object.freeze([
13
+ "like",
14
+ "avoid",
15
+ ]);
16
+
17
+ function cleanText(value, label, maximum = 512) {
18
+ if (typeof value !== "string") throw new TypeError(`${label} is required`);
19
+ const cleaned = value
20
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/gu, "")
21
+ .replace(/[\u202a-\u202e\u2066-\u2069]/gu, "")
22
+ .replace(/\s+/gu, " ")
23
+ .trim();
24
+ if (!cleaned || Array.from(cleaned).length > maximum) {
25
+ throw new TypeError(`${label} is invalid`);
26
+ }
27
+ return cleaned;
28
+ }
29
+
30
+ function optionalNote(value) {
31
+ if (value === undefined || value === null) return null;
32
+ return cleanText(value, "Listener correction note", 2_000);
33
+ }
34
+
35
+ function timestamp(value, label) {
36
+ const milliseconds = Date.parse(value);
37
+ if (typeof value !== "string" || !Number.isFinite(milliseconds)) {
38
+ throw new TypeError(`${label} is invalid`);
39
+ }
40
+ return new Date(milliseconds).toISOString();
41
+ }
42
+
43
+ function normalizedIdentity(value) {
44
+ return value.normalize("NFKC").toLocaleLowerCase("und");
45
+ }
46
+
47
+ function correctionTarget({ entityType, label, artistCredit }) {
48
+ if (!LISTENER_CORRECTION_ENTITY_TYPES.includes(entityType)) {
49
+ throw new TypeError(
50
+ `Listener correction entity type must be one of: ${LISTENER_CORRECTION_ENTITY_TYPES.join(", ")}`,
51
+ );
52
+ }
53
+ const cleanedLabel = cleanText(label, "Listener correction label");
54
+ const cleanedArtist = entityType === "track"
55
+ ? cleanText(artistCredit, "Listener correction track artist")
56
+ : null;
57
+ const identity = [
58
+ entityType,
59
+ normalizedIdentity(cleanedLabel),
60
+ ...(cleanedArtist ? [normalizedIdentity(cleanedArtist)] : []),
61
+ ].join("\0");
62
+ const entityId = uuidV5(identity, LISTENER_CORRECTION_ENTITY_NAMESPACE);
63
+ return {
64
+ target: {
65
+ entity_type: entityType,
66
+ entity_id: entityId,
67
+ entity_revision: 1,
68
+ label: cleanedLabel,
69
+ },
70
+ targetKey: `${entityType}:${entityId}`,
71
+ artistCredit: cleanedArtist,
72
+ };
73
+ }
74
+
75
+ function eventId(value, label) {
76
+ const id = value ?? randomUUID();
77
+ if (!isUuid(id)) throw new TypeError(`${label} is invalid`);
78
+ return id.toLowerCase();
79
+ }
80
+
81
+ export function createListenerCorrection({
82
+ subjectId,
83
+ entityType,
84
+ label,
85
+ artistCredit,
86
+ stance,
87
+ note,
88
+ occurredAt,
89
+ recordedAt = occurredAt,
90
+ supersedesCorrectionId,
91
+ correctionId,
92
+ } = {}) {
93
+ if (!isUuid(subjectId)) {
94
+ throw new TypeError("Listener correction subject is invalid");
95
+ }
96
+ if (!LISTENER_CORRECTION_STANCES.includes(stance)) {
97
+ throw new TypeError(
98
+ `Listener correction stance must be one of: ${LISTENER_CORRECTION_STANCES.join(", ")}`,
99
+ );
100
+ }
101
+ const occurred = timestamp(occurredAt, "Listener correction occurrence time");
102
+ const recorded = timestamp(recordedAt, "Listener correction recording time");
103
+ if (occurred > recorded) {
104
+ throw new TypeError("Listener correction cannot be recorded before it occurred");
105
+ }
106
+ const correctionTargetValue = correctionTarget({
107
+ entityType,
108
+ label,
109
+ artistCredit,
110
+ });
111
+ const supersedes = supersedesCorrectionId === undefined
112
+ ? null
113
+ : eventId(supersedesCorrectionId, "Superseded listener correction ID");
114
+ const cleanedNote = optionalNote(note);
115
+ const id = eventId(correctionId, "Listener correction ID");
116
+ if (supersedes === id) {
117
+ throw new TypeError("A listener correction cannot supersede itself");
118
+ }
119
+ const record = {
120
+ schema_version: LISTENER_CORRECTION_SCHEMA_VERSION,
121
+ taste_event_id: id,
122
+ subject_id: subjectId.toLowerCase(),
123
+ operation: "assert",
124
+ occurred_at: occurred,
125
+ recorded_at: recorded,
126
+ target: correctionTargetValue.target,
127
+ signal_type: stance === "like" ? "preference" : "avoidance",
128
+ polarity: stance === "like" ? "positive" : "negative",
129
+ strength: 1,
130
+ explicitness: "explicit",
131
+ provenance: {
132
+ source_kind: "user_input",
133
+ captured_at: recorded,
134
+ },
135
+ extensions: {
136
+ "moondog.correction_scope": "label_scoped",
137
+ ...(correctionTargetValue.artistCredit
138
+ ? { "moondog.artist_credit": correctionTargetValue.artistCredit }
139
+ : {}),
140
+ },
141
+ ...(cleanedNote ? { note: cleanedNote } : {}),
142
+ ...(supersedes ? { supersedes_taste_event_id: supersedes } : {}),
143
+ };
144
+ return {
145
+ record,
146
+ targetKey: correctionTargetValue.targetKey,
147
+ stance,
148
+ };
149
+ }
150
+
151
+ export function createListenerCorrectionRetraction({
152
+ subjectId,
153
+ retractsCorrectionId,
154
+ occurredAt,
155
+ recordedAt = occurredAt,
156
+ correctionId,
157
+ } = {}) {
158
+ if (!isUuid(subjectId)) {
159
+ throw new TypeError("Listener correction subject is invalid");
160
+ }
161
+ const occurred = timestamp(
162
+ occurredAt,
163
+ "Listener correction retraction occurrence time",
164
+ );
165
+ const recorded = timestamp(
166
+ recordedAt,
167
+ "Listener correction retraction recording time",
168
+ );
169
+ if (occurred > recorded) {
170
+ throw new TypeError(
171
+ "Listener correction retraction cannot be recorded before it occurred",
172
+ );
173
+ }
174
+ const id = eventId(correctionId, "Listener correction retraction ID");
175
+ const retracts = eventId(
176
+ retractsCorrectionId,
177
+ "Retracted listener correction ID",
178
+ );
179
+ if (id === retracts) {
180
+ throw new TypeError("A listener correction cannot retract itself");
181
+ }
182
+ return {
183
+ schema_version: LISTENER_CORRECTION_SCHEMA_VERSION,
184
+ taste_event_id: id,
185
+ subject_id: subjectId.toLowerCase(),
186
+ operation: "retract",
187
+ occurred_at: occurred,
188
+ recorded_at: recorded,
189
+ retracts_taste_event_id: retracts,
190
+ provenance: {
191
+ source_kind: "user_input",
192
+ captured_at: recorded,
193
+ },
194
+ };
195
+ }
196
+
197
+ export function listenerCorrectionTargetKey(record) {
198
+ if (
199
+ !record ||
200
+ record.operation !== "assert" ||
201
+ !LISTENER_CORRECTION_ENTITY_TYPES.includes(record.target?.entity_type) ||
202
+ typeof record.target?.label !== "string"
203
+ ) {
204
+ throw new TypeError("Listener correction target is invalid");
205
+ }
206
+ if (!isUuid(record.target.entity_id)) {
207
+ throw new TypeError("Listener correction target ID is invalid");
208
+ }
209
+ const artistCredit = record.extensions?.["moondog.artist_credit"];
210
+ if (record.target.entity_type === "track") {
211
+ cleanText(
212
+ artistCredit,
213
+ "Listener correction track artist",
214
+ );
215
+ }
216
+ return `${record.target.entity_type}:${record.target.entity_id.toLowerCase()}`;
217
+ }