@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,59 @@
1
+ import { execFile } from "node:child_process";
2
+ import path from "node:path";
3
+ import { promisify } from "node:util";
4
+
5
+ import { readSpotifyAccountDataHistoryArchive } from "./account-data-history.mjs";
6
+ import { readSpotifyExtendedStreamingHistoryArchive } from "./extended-streaming-history.mjs";
7
+
8
+ const execFileAsync = promisify(execFile);
9
+
10
+ function fail(message, code = "spotify_history_archive_invalid") {
11
+ const error = new Error(message);
12
+ error.code = code;
13
+ throw error;
14
+ }
15
+
16
+ export async function readSpotifyHistoryArchive(options = {}) {
17
+ if (typeof options.archivePath !== "string" || !options.archivePath.trim()) {
18
+ fail("A Spotify history ZIP path is required.");
19
+ }
20
+ const archivePath = path.resolve(options.archivePath);
21
+ let listing;
22
+ try {
23
+ ({ stdout: listing } = await execFileAsync(
24
+ "/usr/bin/unzip",
25
+ ["-Z1", archivePath],
26
+ {
27
+ encoding: "utf8",
28
+ maxBuffer: 1024 * 1024,
29
+ windowsHide: true,
30
+ },
31
+ ));
32
+ } catch {
33
+ fail(
34
+ "The Spotify history ZIP could not be read safely.",
35
+ "spotify_history_archive_unreadable",
36
+ );
37
+ }
38
+ const hasAccountData = listing
39
+ .split(/\r?\n/u)
40
+ .some((name) =>
41
+ /^Spotify Account Data\/StreamingHistory_music_[0-9]+\.json$/u.test(
42
+ name,
43
+ ),
44
+ );
45
+ const hasExtendedHistory = listing
46
+ .split(/\r?\n/u)
47
+ .some((name) =>
48
+ /^Spotify Extended Streaming History\/Streaming_History_Audio_[0-9]{4}(?:_[0-9]+)?\.json$/u.test(
49
+ name,
50
+ ),
51
+ );
52
+ if (hasAccountData === hasExtendedHistory) {
53
+ fail("The ZIP does not contain one unambiguous Spotify history format.");
54
+ }
55
+ const reader = hasExtendedHistory
56
+ ? readSpotifyExtendedStreamingHistoryArchive
57
+ : readSpotifyAccountDataHistoryArchive;
58
+ return reader({ ...options, archivePath });
59
+ }
@@ -0,0 +1,80 @@
1
+ export {
2
+ SPOTIFY_WEB_API_BASE_URL,
3
+ SPOTIFY_WEB_API_LIMITS,
4
+ SpotifyWebApiError,
5
+ createSpotifyWebApiClient,
6
+ } from "./web-api-client.mjs";
7
+
8
+ export {
9
+ SPOTIFY_SERVICE_LIMITS,
10
+ SpotifyServiceError,
11
+ createSpotifyService,
12
+ } from "./service.mjs";
13
+
14
+ export {
15
+ SPOTIFY_DEFAULT_REDIRECT_URI,
16
+ SPOTIFY_DEFAULT_SCOPES,
17
+ SpotifyAuthenticationError,
18
+ createSpotifyAuthentication,
19
+ } from "./authentication.mjs";
20
+
21
+ export { createSpotifyCredentialStore } from "./credential-store.mjs";
22
+ export { openSpotifyConnection } from "./connection.mjs";
23
+
24
+ export {
25
+ SPOTIFY_CATALOG_RESOLVER_LIMITS,
26
+ SpotifyCatalogResolverError,
27
+ createSpotifyCatalogResolver,
28
+ } from "./catalog-resolver.mjs";
29
+
30
+ export {
31
+ SPOTIFY_RESOLUTION_CACHE_KEY_VERSION,
32
+ SPOTIFY_RESOLUTION_CACHE_SCHEMA_VERSION,
33
+ createInMemorySpotifyResolutionCache,
34
+ defaultSpotifyResolutionCachePath,
35
+ openSpotifyResolutionCache,
36
+ resolveSpotifyResolutionCachePath,
37
+ spotifyResolutionCacheKey,
38
+ } from "./resolution-cache.mjs";
39
+
40
+ export {
41
+ SPOTIFY_LISTENING_EVENT_NAMESPACE,
42
+ SPOTIFY_RECENT_ACTIVITY_SOURCE,
43
+ SPOTIFY_TRACK_NAMESPACE,
44
+ projectSpotifyRecentActivity,
45
+ } from "./recent-activity.mjs";
46
+
47
+ export {
48
+ SPOTIFY_ACCOUNT_DATA_BATCH_NAMESPACE,
49
+ SPOTIFY_ACCOUNT_DATA_EVENT_NAMESPACE,
50
+ SPOTIFY_ACCOUNT_DATA_HISTORY_FORMAT,
51
+ SPOTIFY_ACCOUNT_DATA_HISTORY_LIMITS,
52
+ SPOTIFY_ACCOUNT_DATA_HISTORY_SCOPE,
53
+ SPOTIFY_ACCOUNT_DATA_HISTORY_SOURCE,
54
+ SPOTIFY_ACCOUNT_DATA_TRACK_NAMESPACE,
55
+ SpotifyAccountDataHistoryError,
56
+ projectSpotifyAccountDataHistory,
57
+ readSpotifyAccountDataHistoryArchive,
58
+ spotifyAccountDataEventIdentity,
59
+ spotifyAccountDataTrackIdentity,
60
+ } from "./account-data-history.mjs";
61
+
62
+ export {
63
+ SPOTIFY_EXTENDED_HISTORY_BATCH_NAMESPACE,
64
+ SPOTIFY_EXTENDED_HISTORY_EVENT_NAMESPACE,
65
+ SPOTIFY_EXTENDED_HISTORY_FORMAT,
66
+ SPOTIFY_EXTENDED_HISTORY_LIMITS,
67
+ SPOTIFY_EXTENDED_HISTORY_SCOPE,
68
+ SPOTIFY_EXTENDED_HISTORY_SOURCE,
69
+ SpotifyExtendedHistoryError,
70
+ projectSpotifyExtendedStreamingHistory,
71
+ readSpotifyExtendedStreamingHistoryArchive,
72
+ } from "./extended-streaming-history.mjs";
73
+
74
+ export { readSpotifyHistoryArchive } from "./history-archive.mjs";
75
+
76
+ export {
77
+ readSpotifyConfiguration,
78
+ resolveSpotifySettingsFile,
79
+ writeSpotifyConfiguration,
80
+ } from "./settings.mjs";
@@ -0,0 +1,198 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ import { isUuid, uuidV5 } from "../../core/uuid-v5.mjs";
4
+
5
+ export const SPOTIFY_RECENT_ACTIVITY_SOURCE = "spotify.recently_played";
6
+ export const SPOTIFY_TRACK_NAMESPACE =
7
+ "e76578a4-0c61-50e2-91f9-b312ea986a92";
8
+ export const SPOTIFY_LISTENING_EVENT_NAMESPACE =
9
+ "b8c8dbbf-2e67-51ae-99e5-744daf9f8c10";
10
+
11
+ const isrcPattern = /^[A-Z]{2}[A-Z0-9]{3}[0-9]{7}$/u;
12
+ const releaseDatePattern = /^(?:[0-9]{4}|[0-9]{4}-(?:0[1-9]|1[0-2])|[0-9]{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12][0-9]|3[01]))$/u;
13
+ const contextTypes = new Set(["album", "playlist", "artist"]);
14
+
15
+ function fail(message) {
16
+ throw new TypeError(message);
17
+ }
18
+
19
+ function timestamp(value, label) {
20
+ if (typeof value !== "string") fail(`${label} is invalid`);
21
+ const milliseconds = Date.parse(value);
22
+ if (!Number.isFinite(milliseconds)) fail(`${label} is invalid`);
23
+ const normalized = new Date(milliseconds).toISOString();
24
+ if (normalized !== value) fail(`${label} must be a normalized UTC timestamp`);
25
+ return normalized;
26
+ }
27
+
28
+ function cleanText(value, maximum, label) {
29
+ if (typeof value !== "string") fail(`${label} is invalid`);
30
+ const cleaned = value.replace(/\s+/gu, " ").trim();
31
+ if (!cleaned || Array.from(cleaned).length > maximum) {
32
+ fail(`${label} is invalid`);
33
+ }
34
+ return cleaned;
35
+ }
36
+
37
+ function clippedText(value, maximum) {
38
+ return Array.from(value).slice(0, maximum).join("");
39
+ }
40
+
41
+ function validReleaseDate(value) {
42
+ if (typeof value !== "string" || !releaseDatePattern.test(value)) {
43
+ return false;
44
+ }
45
+ if (value.length < 10) return true;
46
+ const milliseconds = Date.parse(`${value}T00:00:00.000Z`);
47
+ return (
48
+ Number.isFinite(milliseconds) &&
49
+ new Date(milliseconds).toISOString().slice(0, 10) === value
50
+ );
51
+ }
52
+
53
+ function safeExternalUrl(value) {
54
+ if (typeof value !== "string") return undefined;
55
+ try {
56
+ const url = new URL(value);
57
+ if (
58
+ url.protocol !== "https:" ||
59
+ url.search.length > 0 ||
60
+ url.hash.length > 0 ||
61
+ url.username.length > 0 ||
62
+ url.password.length > 0
63
+ ) {
64
+ return undefined;
65
+ }
66
+ return url.toString();
67
+ } catch {
68
+ return undefined;
69
+ }
70
+ }
71
+
72
+ function trackRecord(item) {
73
+ const track = item.track;
74
+ const playedAt = timestamp(item.played_at, "Spotify played_at");
75
+ const externalId = cleanText(track.id, 128, "Spotify track ID");
76
+ const title = cleanText(track.name, 512, "Spotify track title");
77
+ const artists = Array.isArray(track.artists)
78
+ ? track.artists
79
+ .map((artist) => cleanText(artist, 512, "Spotify artist name"))
80
+ .slice(0, 5)
81
+ : [];
82
+ if (artists.length === 0) fail("Spotify track artists are invalid");
83
+ const release = cleanText(track.album, 512, "Spotify release title");
84
+ const externalRef = {
85
+ system: "spotify",
86
+ entity_type: "spotify.track",
87
+ external_id: externalId,
88
+ };
89
+ const url = safeExternalUrl(track.external_url);
90
+ if (url) externalRef.url = url;
91
+ const record = {
92
+ schema_version: 1,
93
+ track_ref_id: uuidV5(externalId, SPOTIFY_TRACK_NAMESPACE),
94
+ revision: 1,
95
+ identity_status: "resolved",
96
+ display_label: clippedText(`${title} - ${artists.join(", ")}`, 512),
97
+ title,
98
+ artist_credits: artists.map((name) => ({ name, role: "unknown" })),
99
+ release: { title: release },
100
+ external_refs: [externalRef],
101
+ created_at: playedAt,
102
+ resolved_at: playedAt,
103
+ };
104
+ if (Number.isInteger(track.duration_ms) && track.duration_ms >= 0) {
105
+ record.duration_ms = track.duration_ms;
106
+ }
107
+ if (typeof track.isrc === "string" && isrcPattern.test(track.isrc)) {
108
+ record.isrc = track.isrc;
109
+ }
110
+ if (
111
+ validReleaseDate(track.release_date)
112
+ ) {
113
+ record.release.release_date = track.release_date;
114
+ }
115
+ return record;
116
+ }
117
+
118
+ function eventRecord(item, trackRef, subjectId, capturedAt) {
119
+ const occurredAt = timestamp(item.played_at, "Spotify played_at");
120
+ if (Date.parse(capturedAt) < Date.parse(occurredAt)) {
121
+ fail("Spotify play time is later than its capture time");
122
+ }
123
+ const fingerprint = createHash("sha256")
124
+ .update("spotify\0")
125
+ .update(subjectId)
126
+ .update("\0")
127
+ .update(item.track.id)
128
+ .update("\0")
129
+ .update(occurredAt)
130
+ .digest("hex");
131
+ const contextType = contextTypes.has(item.context_type)
132
+ ? item.context_type
133
+ : "unknown";
134
+ return {
135
+ schema_version: 1,
136
+ listening_event_id: uuidV5(
137
+ fingerprint,
138
+ SPOTIFY_LISTENING_EVENT_NAMESPACE,
139
+ ),
140
+ subject_id: subjectId,
141
+ track_ref_id: trackRef.track_ref_id,
142
+ track_ref_revision: trackRef.revision,
143
+ event_type: "play_observed",
144
+ occurred_at: occurredAt,
145
+ ingested_at: capturedAt,
146
+ interaction_mode: "unknown",
147
+ context: { context_type: contextType },
148
+ provenance: {
149
+ source_kind: "tool",
150
+ captured_at: capturedAt,
151
+ external_ref: {
152
+ system: "spotify",
153
+ entity_type: "spotify.play_history",
154
+ external_id: fingerprint,
155
+ },
156
+ },
157
+ deduplication_key: fingerprint,
158
+ };
159
+ }
160
+
161
+ export function projectSpotifyRecentActivity({
162
+ subjectId,
163
+ page,
164
+ capturedAt = new Date().toISOString(),
165
+ } = {}) {
166
+ if (!isUuid(subjectId)) fail("A trusted subject ID is required");
167
+ const normalizedCapturedAt = timestamp(capturedAt, "Capture timestamp");
168
+ if (!page || page.provider !== "spotify" || !Array.isArray(page.items)) {
169
+ fail("A normalized Spotify recent activity page is required");
170
+ }
171
+ if (page.items.length > 50) fail("Spotify recent activity is not bounded");
172
+
173
+ const trackRefs = new Map();
174
+ const listeningEvents = new Map();
175
+ let cursorAfterMs = Number.isSafeInteger(page.cursor_after_ms)
176
+ ? page.cursor_after_ms
177
+ : 0;
178
+ for (const item of page.items) {
179
+ const trackRef = trackRecord(item);
180
+ trackRefs.set(trackRef.track_ref_id, trackRef);
181
+ const event = eventRecord(
182
+ item,
183
+ trackRef,
184
+ subjectId.toLowerCase(),
185
+ normalizedCapturedAt,
186
+ );
187
+ listeningEvents.set(event.listening_event_id, event);
188
+ cursorAfterMs = Math.max(cursorAfterMs, Date.parse(event.occurred_at));
189
+ }
190
+
191
+ return {
192
+ source_key: SPOTIFY_RECENT_ACTIVITY_SOURCE,
193
+ captured_at: normalizedCapturedAt,
194
+ cursor_after_ms: cursorAfterMs > 0 ? cursorAfterMs : null,
195
+ track_refs: [...trackRefs.values()],
196
+ listening_events: [...listeningEvents.values()],
197
+ };
198
+ }
@@ -0,0 +1,260 @@
1
+ import { createHash } from "node:crypto";
2
+ import { chmod, mkdir } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import path from "node:path";
5
+
6
+ export const SPOTIFY_RESOLUTION_CACHE_SCHEMA_VERSION = 1;
7
+ export const SPOTIFY_RESOLUTION_CACHE_KEY_VERSION = 2;
8
+
9
+ export function resolveSpotifyResolutionCachePath(environment = process.env) {
10
+ const configuredState = environment.MOONDOG_STATE_HOME?.trim();
11
+ if (configuredState) {
12
+ if (!path.isAbsolute(configuredState)) {
13
+ throw new TypeError("MOONDOG_STATE_HOME must be an absolute path");
14
+ }
15
+ return path.join(path.resolve(configuredState), "spotify-resolution-cache.sqlite");
16
+ }
17
+
18
+ const configuredConfig = environment.MOONDOG_CONFIG_HOME?.trim();
19
+ if (configuredConfig) {
20
+ if (!path.isAbsolute(configuredConfig)) {
21
+ throw new TypeError("MOONDOG_CONFIG_HOME must be an absolute path");
22
+ }
23
+ return path.join(
24
+ path.resolve(configuredConfig),
25
+ "spotify-resolution-cache.sqlite",
26
+ );
27
+ }
28
+
29
+ const xdgState = environment.XDG_STATE_HOME?.trim();
30
+ if (xdgState && path.isAbsolute(xdgState)) {
31
+ return path.join(
32
+ path.resolve(xdgState),
33
+ "moondog",
34
+ "spotify-resolution-cache.sqlite",
35
+ );
36
+ }
37
+
38
+ return path.join(
39
+ homedir(),
40
+ ".local",
41
+ "state",
42
+ "moondog",
43
+ "spotify-resolution-cache.sqlite",
44
+ );
45
+ }
46
+
47
+ export const defaultSpotifyResolutionCachePath =
48
+ resolveSpotifyResolutionCachePath();
49
+
50
+ const schema = `
51
+ CREATE TABLE IF NOT EXISTS track_resolutions (
52
+ cache_key TEXT PRIMARY KEY,
53
+ status TEXT NOT NULL,
54
+ match_quality TEXT,
55
+ matched_title TEXT,
56
+ matched_artists TEXT,
57
+ matched_album TEXT,
58
+ matched_duration_ms INTEGER,
59
+ spotify_track_id TEXT,
60
+ spotify_uri TEXT,
61
+ resolved_at_ms INTEGER NOT NULL
62
+ );
63
+ `;
64
+
65
+ const matchQualityValues = new Set(["alternate_master", "exact", "standard"]);
66
+
67
+ function isPlainObject(value) {
68
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
69
+ const prototype = Object.getPrototypeOf(value);
70
+ return prototype === Object.prototype || prototype === null;
71
+ }
72
+
73
+ function cleanStoredText(value, maximum) {
74
+ if (typeof value !== "string") return null;
75
+ const cleaned = value.replace(/\s+/gu, " ").trim();
76
+ if (!cleaned) return null;
77
+ return Array.from(cleaned).slice(0, maximum).join("");
78
+ }
79
+
80
+ export function spotifyResolutionCacheKey(track) {
81
+ if (
82
+ !isPlainObject(track) ||
83
+ typeof track.title !== "string" ||
84
+ typeof track.artist_credit !== "string" ||
85
+ typeof track.release !== "string"
86
+ ) {
87
+ throw new TypeError("A track title, artist credit, and release are required.");
88
+ }
89
+ const normalized = [
90
+ `spotify-resolution/${SPOTIFY_RESOLUTION_CACHE_KEY_VERSION}`,
91
+ track.title,
92
+ track.artist_credit,
93
+ track.release,
94
+ ]
95
+ .map((value) => value.normalize("NFKC").toLocaleLowerCase("und").replace(/\s+/gu, " ").trim())
96
+ .join("|");
97
+ return createHash("sha256").update(normalized).digest("hex");
98
+ }
99
+
100
+ function serializeArtists(value) {
101
+ if (!Array.isArray(value)) return null;
102
+ const artists = value
103
+ .filter((artist) => typeof artist === "string" && artist.trim())
104
+ .map((artist) => cleanStoredText(artist, 256))
105
+ .filter(Boolean)
106
+ .slice(0, 5);
107
+ return artists.length > 0 ? JSON.stringify(artists) : null;
108
+ }
109
+
110
+ function parseArtists(value) {
111
+ if (typeof value !== "string" || !value.trim()) return undefined;
112
+ try {
113
+ const parsed = JSON.parse(value);
114
+ return Array.isArray(parsed) ? parsed.slice(0, 5) : undefined;
115
+ } catch {
116
+ return undefined;
117
+ }
118
+ }
119
+
120
+ function rowToEntry(row) {
121
+ if (!row || row.status !== "resolved") return null;
122
+ const entry = {
123
+ status: "resolved",
124
+ match_quality: matchQualityValues.has(row.match_quality)
125
+ ? row.match_quality
126
+ : "standard",
127
+ };
128
+ if (row.matched_title) entry.matched = { title: row.matched_title };
129
+ const artists = parseArtists(row.matched_artists);
130
+ if (artists) entry.matched.artists = artists;
131
+ if (row.matched_album) entry.matched.album = row.matched_album;
132
+ if (Number.isInteger(row.matched_duration_ms)) {
133
+ entry.matched.duration_ms = row.matched_duration_ms;
134
+ }
135
+ if (row.spotify_track_id && row.spotify_uri) {
136
+ entry.spotify = {
137
+ track_id: row.spotify_track_id,
138
+ uri: row.spotify_uri,
139
+ };
140
+ }
141
+ if (!entry.spotify) return null;
142
+ return entry;
143
+ }
144
+
145
+ export class SpotifyResolutionCache {
146
+ #database;
147
+ #closed = false;
148
+
149
+ constructor(database) {
150
+ this.#database = database;
151
+ }
152
+
153
+ async get(cacheKey) {
154
+ if (this.#closed) return null;
155
+ const row = this.#database
156
+ .prepare("SELECT * FROM track_resolutions WHERE cache_key = ?")
157
+ .get(cacheKey);
158
+ return rowToEntry(row);
159
+ }
160
+
161
+ async put(cacheKey, entry, resolvedAtMs = Date.now()) {
162
+ if (this.#closed) return;
163
+ if (!isPlainObject(entry) || entry.status !== "resolved") return;
164
+ const matched = isPlainObject(entry.matched) ? entry.matched : {};
165
+ const spotify = isPlainObject(entry.spotify) ? entry.spotify : {};
166
+ this.#database
167
+ .prepare(
168
+ `INSERT OR REPLACE INTO track_resolutions (
169
+ cache_key, status, match_quality, matched_title, matched_artists,
170
+ matched_album, matched_duration_ms, spotify_track_id, spotify_uri,
171
+ resolved_at_ms
172
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
173
+ )
174
+ .run(
175
+ cacheKey,
176
+ "resolved",
177
+ matchQualityValues.has(entry.match_quality)
178
+ ? entry.match_quality
179
+ : "standard",
180
+ cleanStoredText(matched.title, 512),
181
+ serializeArtists(matched.artists),
182
+ cleanStoredText(matched.album, 512),
183
+ Number.isInteger(matched.duration_ms) ? matched.duration_ms : null,
184
+ cleanStoredText(spotify.track_id, 128),
185
+ cleanStoredText(spotify.uri, 256),
186
+ Number.isInteger(resolvedAtMs) ? resolvedAtMs : Date.now(),
187
+ );
188
+ }
189
+
190
+ close() {
191
+ if (this.#closed) return;
192
+ this.#closed = true;
193
+ this.#database.close();
194
+ }
195
+ }
196
+
197
+ let databaseConstructorPromise;
198
+
199
+ function databaseConstructor() {
200
+ databaseConstructorPromise ??= import("node:sqlite").then(
201
+ ({ DatabaseSync }) => DatabaseSync,
202
+ );
203
+ return databaseConstructorPromise;
204
+ }
205
+
206
+ export async function openSpotifyResolutionCache({
207
+ databasePath = defaultSpotifyResolutionCachePath,
208
+ environment = process.env,
209
+ } = {}) {
210
+ const resolvedPath =
211
+ databasePath === defaultSpotifyResolutionCachePath
212
+ ? resolveSpotifyResolutionCachePath(environment)
213
+ : databasePath;
214
+ const parent = path.dirname(path.resolve(resolvedPath));
215
+ await mkdir(parent, { recursive: true, mode: 0o700 });
216
+ if (process.platform !== "win32") await chmod(parent, 0o700);
217
+
218
+ const DatabaseSync = await databaseConstructor();
219
+ const database = new DatabaseSync(path.resolve(resolvedPath));
220
+ try {
221
+ database.exec("PRAGMA busy_timeout = 5000");
222
+ database.exec("PRAGMA journal_mode = WAL");
223
+ database.exec("PRAGMA trusted_schema = OFF");
224
+ const currentVersion = database.prepare("PRAGMA user_version").get()
225
+ .user_version;
226
+ if (
227
+ !Number.isInteger(currentVersion) ||
228
+ currentVersion < 0 ||
229
+ currentVersion > SPOTIFY_RESOLUTION_CACHE_SCHEMA_VERSION
230
+ ) {
231
+ throw new Error("Spotify resolution cache schema is incompatible");
232
+ }
233
+ database.exec(schema);
234
+ if (currentVersion < SPOTIFY_RESOLUTION_CACHE_SCHEMA_VERSION) {
235
+ database.exec(
236
+ `PRAGMA user_version = ${SPOTIFY_RESOLUTION_CACHE_SCHEMA_VERSION}`,
237
+ );
238
+ }
239
+ if (process.platform !== "win32") {
240
+ await chmod(path.resolve(resolvedPath), 0o600);
241
+ }
242
+ return new SpotifyResolutionCache(database);
243
+ } catch (error) {
244
+ database.close();
245
+ throw error;
246
+ }
247
+ }
248
+
249
+ export function createInMemorySpotifyResolutionCache() {
250
+ const entries = new Map();
251
+ return {
252
+ async get(cacheKey) {
253
+ return entries.get(cacheKey) ?? null;
254
+ },
255
+ async put(cacheKey, entry) {
256
+ entries.set(cacheKey, structuredClone(entry));
257
+ },
258
+ async close() {},
259
+ };
260
+ }