@audiofool/moondog 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +125 -0
- package/assets/brand/moondog-lunar-record/moondog-logo-1x1.png +0 -0
- package/contracts/v1/capability-policy.schema.json +572 -0
- package/contracts/v1/definitions.schema.json +365 -0
- package/contracts/v1/library-track-observation.schema.json +138 -0
- package/contracts/v1/listening-event.schema.json +111 -0
- package/contracts/v1/profile-evidence.schema.json +237 -0
- package/contracts/v1/taste-event.schema.json +258 -0
- package/contracts/v1/tool-invocation.schema.json +318 -0
- package/contracts/v1/tool-receipt.schema.json +1066 -0
- package/contracts/v1/track-ref.schema.json +275 -0
- package/contracts/v2/profile-evidence.schema.json +284 -0
- package/package.json +105 -0
- package/scripts/contract-lib.mjs +361 -0
- package/scripts/contract-semantics.mjs +2630 -0
- package/scripts/demo-studio.mjs +58 -0
- package/scripts/moondog +52 -0
- package/scripts/moondog.mjs +954 -0
- package/src/core/apple-data-migration.mjs +50 -0
- package/src/core/apple-library-source-status.mjs +220 -0
- package/src/core/apple-projection-domain-services.mjs +2356 -0
- package/src/core/capability-catalog.mjs +577 -0
- package/src/core/listening-profile-domain-services.mjs +1010 -0
- package/src/core/lyric-library.mjs +121 -0
- package/src/core/lyric-profile.mjs +50 -0
- package/src/core/lyric-service.mjs +41 -0
- package/src/core/moondog-application.mjs +1827 -0
- package/src/core/state-directory.mjs +19 -0
- package/src/core/synthetic-domain-services.mjs +1485 -0
- package/src/core/uuid-v5.mjs +42 -0
- package/src/demo/fictional-spotify-history.mjs +400 -0
- package/src/demo/moondog-demo.mjs +355 -0
- package/src/demo/moondog-interactive-tasteprint-demo.mjs +293 -0
- package/src/demo/moondog-tasteprint-demo.mjs +631 -0
- package/src/importers/apple-music-library/import-batch.mjs +290 -0
- package/src/importers/apple-music-library/index.mjs +76 -0
- package/src/importers/apple-music-library/normalize.mjs +1126 -0
- package/src/importers/apple-music-library/parse-plist.mjs +357 -0
- package/src/importers/apple-music-library/projection-records.mjs +557 -0
- package/src/importers/apple-music-library/read-batch.mjs +276 -0
- package/src/importers/apple-music-library/sqlite-projection.mjs +1307 -0
- package/src/importers/apple-music-library/stable-ids.mjs +19 -0
- package/src/importers/apple-music-library/track-snapshot.schema.json +136 -0
- package/src/integrations/apple-music/catalog.mjs +770 -0
- package/src/integrations/cross-catalog-artist-identity.mjs +222 -0
- package/src/integrations/listenbrainz/history-file.mjs +560 -0
- package/src/integrations/lyrics/lrclib.mjs +55 -0
- package/src/integrations/open-music-similarity/artist-radio.mjs +806 -0
- package/src/integrations/public-playlists.mjs +104 -0
- package/src/integrations/spotify/account-data-history.mjs +545 -0
- package/src/integrations/spotify/account-data-profile.mjs +918 -0
- package/src/integrations/spotify/authentication.mjs +791 -0
- package/src/integrations/spotify/catalog-resolver.mjs +657 -0
- package/src/integrations/spotify/connection.mjs +125 -0
- package/src/integrations/spotify/credential-store.mjs +26 -0
- package/src/integrations/spotify/extended-streaming-history.mjs +525 -0
- package/src/integrations/spotify/history-archive.mjs +59 -0
- package/src/integrations/spotify/index.mjs +80 -0
- package/src/integrations/spotify/recent-activity.mjs +198 -0
- package/src/integrations/spotify/resolution-cache.mjs +260 -0
- package/src/integrations/spotify/service.mjs +713 -0
- package/src/integrations/spotify/settings.mjs +89 -0
- package/src/integrations/spotify/web-api-client.mjs +761 -0
- package/src/integrations/web/codex-web.mjs +263 -0
- package/src/integrations/youtube-music/takeout.mjs +143 -0
- package/src/memory/local-memory-store.mjs +1705 -0
- package/src/memory/reflection-worker.mjs +87 -0
- package/src/profile/apple-library-import.mjs +28 -0
- package/src/profile/history-import.mjs +163 -0
- package/src/profile/listener-corrections.mjs +217 -0
- package/src/profile/listening-history-store.mjs +2055 -0
- package/src/profile/listening-profile-projection.mjs +2520 -0
- package/src/profile/local-music-data.mjs +812 -0
- package/src/profile/music-import-bundle.mjs +91 -0
- package/src/profile/music-providers.mjs +25 -0
- package/src/profile/spotify-archive-catalog-hints.mjs +210 -0
- package/src/profile/spotify-archive-taste.mjs +482 -0
- package/src/runtime/pi/agent-runtime.mjs +6444 -0
- package/src/runtime/pi/authentication.mjs +104 -0
- package/src/runtime/pi/configured-runtime.mjs +98 -0
- package/src/runtime/pi/memory-agent-runtime.mjs +337 -0
- package/src/runtime/pi/model-catalog.mjs +62 -0
- package/src/runtime/pi/model-transport.mjs +117 -0
- package/src/runtime/pi/persistent-credential-store.mjs +670 -0
- package/src/runtime/pi/provider-registry.mjs +74 -0
- package/src/runtime/pi/runtime-settings.mjs +81 -0
- package/src/surfaces/cli/auth-command.mjs +355 -0
- package/src/surfaces/cli/brand-components.mjs +390 -0
- package/src/surfaces/cli/brand-theme.mjs +231 -0
- package/src/surfaces/cli/catalog-command.mjs +509 -0
- package/src/surfaces/cli/command-completions.mjs +113 -0
- package/src/surfaces/cli/data-command.mjs +214 -0
- package/src/surfaces/cli/format-output.mjs +750 -0
- package/src/surfaces/cli/history-import-view.mjs +445 -0
- package/src/surfaces/cli/import-guides.mjs +9 -0
- package/src/surfaces/cli/listenbrainz-command.mjs +129 -0
- package/src/surfaces/cli/listening-menu.mjs +171 -0
- package/src/surfaces/cli/profile-command.mjs +290 -0
- package/src/surfaces/cli/spotify-command.mjs +849 -0
- package/src/surfaces/cli/taste-profile-model.mjs +306 -0
- package/src/surfaces/cli/taste-profile-view.mjs +272 -0
- package/src/surfaces/cli/terminal-art.mjs +295 -0
- package/src/surfaces/cli/tui.mjs +1714 -0
- package/src/surfaces/cli/web-command.mjs +52 -0
- package/src/surfaces/html/tasteprint-artifact.mjs +96 -0
- package/src/surfaces/html/tasteprint.mjs +3575 -0
- package/src/surfaces/web/showcase.mjs +272 -0
- package/src/surfaces/web/studio-page.mjs +4148 -0
- package/src/surfaces/web/studio.mjs +2288 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { createSpotifyAuthentication, SPOTIFY_DEFAULT_SCOPES } from "./authentication.mjs";
|
|
2
|
+
import { createSpotifyCatalogResolver } from "./catalog-resolver.mjs";
|
|
3
|
+
import { createSpotifyCredentialStore } from "./credential-store.mjs";
|
|
4
|
+
import { createSpotifyService } from "./service.mjs";
|
|
5
|
+
import { readSpotifyConfiguration } from "./settings.mjs";
|
|
6
|
+
import { createSpotifyWebApiClient } from "./web-api-client.mjs";
|
|
7
|
+
|
|
8
|
+
function disconnectedStatus(reason = "client_id_required") {
|
|
9
|
+
return {
|
|
10
|
+
provider: "spotify",
|
|
11
|
+
state: "not_configured",
|
|
12
|
+
reason,
|
|
13
|
+
authentication: "not_configured",
|
|
14
|
+
client_id_configured: false,
|
|
15
|
+
external_effects: "disabled",
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function grantedScopes(authenticationStatus) {
|
|
20
|
+
if (typeof authenticationStatus?.scopes !== "string") return [];
|
|
21
|
+
return authenticationStatus.scopes.split(/\s+/u).filter(Boolean).sort();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function connectedStatus(authenticationStatus, now = Date.now()) {
|
|
25
|
+
const stored = authenticationStatus?.state === "stored";
|
|
26
|
+
const refreshValid =
|
|
27
|
+
stored &&
|
|
28
|
+
Number.isSafeInteger(authenticationStatus.refreshExpiresAt) &&
|
|
29
|
+
authenticationStatus.refreshExpiresAt > now;
|
|
30
|
+
const granted = grantedScopes(authenticationStatus);
|
|
31
|
+
const missingScopes = SPOTIFY_DEFAULT_SCOPES.filter(
|
|
32
|
+
(scope) => !granted.includes(scope),
|
|
33
|
+
);
|
|
34
|
+
return {
|
|
35
|
+
provider: "spotify",
|
|
36
|
+
state: refreshValid ? "ready" : stored ? "reauthentication_required" : "not_authenticated",
|
|
37
|
+
authentication: stored ? "stored" : "not_configured",
|
|
38
|
+
client_id_configured: true,
|
|
39
|
+
access_expires_at: authenticationStatus?.accessExpiresAt ?? null,
|
|
40
|
+
refresh_expires_at: authenticationStatus?.refreshExpiresAt ?? null,
|
|
41
|
+
external_effects: refreshValid ? "spotify_control" : "disabled",
|
|
42
|
+
scopes: {
|
|
43
|
+
granted,
|
|
44
|
+
missing: missingScopes,
|
|
45
|
+
sufficient: missingScopes.length === 0,
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function openSpotifyConnection({
|
|
51
|
+
environment = process.env,
|
|
52
|
+
credentialStore,
|
|
53
|
+
fetchImpl = globalThis.fetch,
|
|
54
|
+
openBrowser,
|
|
55
|
+
now = Date.now,
|
|
56
|
+
resolutionCache = null,
|
|
57
|
+
} = {}) {
|
|
58
|
+
const configuration = await readSpotifyConfiguration(environment);
|
|
59
|
+
if (!configuration) {
|
|
60
|
+
return {
|
|
61
|
+
service: null,
|
|
62
|
+
resolver: null,
|
|
63
|
+
authentication: null,
|
|
64
|
+
ready: () => false,
|
|
65
|
+
publicStatus: () => disconnectedStatus(),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const spotifyCredentialStore = createSpotifyCredentialStore({
|
|
70
|
+
environment,
|
|
71
|
+
...(credentialStore ? { credentialStore } : {}),
|
|
72
|
+
});
|
|
73
|
+
const authentication = createSpotifyAuthentication({
|
|
74
|
+
clientId: configuration.clientId,
|
|
75
|
+
credentialStore: spotifyCredentialStore,
|
|
76
|
+
fetchImpl,
|
|
77
|
+
...(openBrowser ? { openBrowser } : {}),
|
|
78
|
+
now,
|
|
79
|
+
});
|
|
80
|
+
const client = createSpotifyWebApiClient({
|
|
81
|
+
fetchImpl,
|
|
82
|
+
tokenProvider: () => authentication.getAccessToken(),
|
|
83
|
+
});
|
|
84
|
+
const service = createSpotifyService({ client });
|
|
85
|
+
const resolver = createSpotifyCatalogResolver({
|
|
86
|
+
client,
|
|
87
|
+
...(resolutionCache ? { cache: resolutionCache } : {}),
|
|
88
|
+
});
|
|
89
|
+
let status = connectedStatus(await authentication.status(), now());
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
service,
|
|
93
|
+
resolver,
|
|
94
|
+
authentication,
|
|
95
|
+
|
|
96
|
+
ready() {
|
|
97
|
+
return status.state === "ready";
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
publicStatus() {
|
|
101
|
+
return structuredClone(status);
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
missingScopes() {
|
|
105
|
+
return [...status.scopes.missing];
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
async login(options) {
|
|
109
|
+
const authenticationStatus = await authentication.login(options);
|
|
110
|
+
status = connectedStatus(authenticationStatus, now());
|
|
111
|
+
return structuredClone(status);
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
async logout(options) {
|
|
115
|
+
const authenticationStatus = await authentication.logout(options);
|
|
116
|
+
status = connectedStatus(authenticationStatus, now());
|
|
117
|
+
return structuredClone(status);
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
async refreshStatus(options) {
|
|
121
|
+
status = connectedStatus(await authentication.status(options), now());
|
|
122
|
+
return structuredClone(status);
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { createPersistentCredentialStore } from "../../runtime/pi/persistent-credential-store.mjs";
|
|
2
|
+
|
|
3
|
+
const providerId = "spotify";
|
|
4
|
+
|
|
5
|
+
export function createSpotifyCredentialStore({
|
|
6
|
+
environment = process.env,
|
|
7
|
+
credentialStore = createPersistentCredentialStore(environment),
|
|
8
|
+
} = {}) {
|
|
9
|
+
return {
|
|
10
|
+
async read(options) {
|
|
11
|
+
return credentialStore.read(providerId, options);
|
|
12
|
+
},
|
|
13
|
+
|
|
14
|
+
async write(credential, options) {
|
|
15
|
+
return credentialStore.modify(
|
|
16
|
+
providerId,
|
|
17
|
+
async () => ({ type: "oauth", ...credential }),
|
|
18
|
+
options,
|
|
19
|
+
);
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
async delete(options) {
|
|
23
|
+
await credentialStore.delete(providerId, options);
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { createReadStream } from "node:fs";
|
|
4
|
+
import { lstat } from "node:fs/promises";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
|
|
8
|
+
import { isUuid, uuidV5 } from "../../core/uuid-v5.mjs";
|
|
9
|
+
import {
|
|
10
|
+
spotifyAccountDataEventIdentity,
|
|
11
|
+
} from "./account-data-history.mjs";
|
|
12
|
+
import { SPOTIFY_TRACK_NAMESPACE } from "./recent-activity.mjs";
|
|
13
|
+
|
|
14
|
+
export const SPOTIFY_EXTENDED_HISTORY_SOURCE =
|
|
15
|
+
"spotify.extended_streaming_history.music";
|
|
16
|
+
export const SPOTIFY_EXTENDED_HISTORY_FORMAT =
|
|
17
|
+
"spotify_extended_streaming_history_music_v1";
|
|
18
|
+
export const SPOTIFY_EXTENDED_HISTORY_SCOPE =
|
|
19
|
+
"lifetime_extended_streaming_history";
|
|
20
|
+
export const SPOTIFY_EXTENDED_HISTORY_EVENT_NAMESPACE =
|
|
21
|
+
"8ec283b4-13d8-50a4-bb3d-f67bb3f0d2c6";
|
|
22
|
+
export const SPOTIFY_EXTENDED_HISTORY_BATCH_NAMESPACE =
|
|
23
|
+
"584a5465-d764-59e0-91d2-1be1e51beaef";
|
|
24
|
+
|
|
25
|
+
export const SPOTIFY_EXTENDED_HISTORY_LIMITS = Object.freeze({
|
|
26
|
+
maximumArchiveBytes: 512 * 1024 * 1024,
|
|
27
|
+
maximumHistoryMembers: 100,
|
|
28
|
+
maximumMemberBytes: 64 * 1024 * 1024,
|
|
29
|
+
maximumTotalJsonBytes: 256 * 1024 * 1024,
|
|
30
|
+
maximumRecords: 500_000,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const execFileAsync = promisify(execFile);
|
|
34
|
+
const audioMemberPattern =
|
|
35
|
+
/^Spotify Extended Streaming History\/Streaming_History_Audio_([0-9]{4})(?:_([0-9]+))?\.json$/u;
|
|
36
|
+
const trackUriPattern = /^spotify:track:([A-Za-z0-9]{22})$/u;
|
|
37
|
+
const sha256Pattern = /^[a-f0-9]{64}$/u;
|
|
38
|
+
const unsafeTextPattern =
|
|
39
|
+
/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/gu;
|
|
40
|
+
|
|
41
|
+
export class SpotifyExtendedHistoryError extends Error {
|
|
42
|
+
constructor(message, code = "spotify_extended_history_invalid") {
|
|
43
|
+
super(message);
|
|
44
|
+
this.name = "SpotifyExtendedHistoryError";
|
|
45
|
+
this.code = code;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function fail(message, code) {
|
|
50
|
+
throw new SpotifyExtendedHistoryError(message, code);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function timestamp(value, label) {
|
|
54
|
+
if (
|
|
55
|
+
typeof value !== "string" ||
|
|
56
|
+
!/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]+)?Z$/u.test(value)
|
|
57
|
+
) {
|
|
58
|
+
fail(`${label} is invalid.`);
|
|
59
|
+
}
|
|
60
|
+
const milliseconds = Date.parse(value);
|
|
61
|
+
if (!Number.isFinite(milliseconds)) fail(`${label} is invalid.`);
|
|
62
|
+
return new Date(milliseconds).toISOString();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function cleanText(value, maximum, label, index) {
|
|
66
|
+
if (typeof value !== "string") {
|
|
67
|
+
fail(`Spotify Extended History record ${index + 1} has an invalid ${label}.`);
|
|
68
|
+
}
|
|
69
|
+
const cleaned = value
|
|
70
|
+
.normalize("NFC")
|
|
71
|
+
.replace(unsafeTextPattern, " ")
|
|
72
|
+
.replace(/\s+/gu, " ")
|
|
73
|
+
.trim();
|
|
74
|
+
if (!cleaned || Array.from(cleaned).length > maximum) {
|
|
75
|
+
fail(`Spotify Extended History record ${index + 1} has an invalid ${label}.`);
|
|
76
|
+
}
|
|
77
|
+
return cleaned;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function optionalText(value, maximum, label, index) {
|
|
81
|
+
if (value === null || value === undefined) return null;
|
|
82
|
+
return cleanText(value, maximum, label, index);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function nullableBoolean(value, label, index) {
|
|
86
|
+
if (value === null || typeof value === "boolean") return value;
|
|
87
|
+
fail(`Spotify Extended History record ${index + 1} has an invalid ${label}.`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function hashParts(...parts) {
|
|
91
|
+
const hash = createHash("sha256");
|
|
92
|
+
for (const part of parts) hash.update(String(part)).update("\0");
|
|
93
|
+
return hash.digest("hex");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function clippedText(value, maximum = 512) {
|
|
97
|
+
return Array.from(value).slice(0, maximum).join("");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function lexicalCompare(left, right) {
|
|
101
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function standardOccurredAt(occurredAt) {
|
|
105
|
+
return `${occurredAt.slice(0, 16)}:00.000Z`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function normalizedMusicRows(records, capturedAt) {
|
|
109
|
+
if (!Array.isArray(records)) {
|
|
110
|
+
fail("Spotify Extended History records are invalid.");
|
|
111
|
+
}
|
|
112
|
+
if (records.length > SPOTIFY_EXTENDED_HISTORY_LIMITS.maximumRecords) {
|
|
113
|
+
fail("Spotify Extended History contains too many records.");
|
|
114
|
+
}
|
|
115
|
+
const capturedMilliseconds = Date.parse(capturedAt);
|
|
116
|
+
const rows = [];
|
|
117
|
+
for (let index = 0; index < records.length; index += 1) {
|
|
118
|
+
const record = records[index];
|
|
119
|
+
if (!record || typeof record !== "object" || Array.isArray(record)) {
|
|
120
|
+
fail(`Spotify Extended History record ${index + 1} is invalid.`);
|
|
121
|
+
}
|
|
122
|
+
if (record.spotify_track_uri === null) continue;
|
|
123
|
+
const uri = cleanText(
|
|
124
|
+
record.spotify_track_uri,
|
|
125
|
+
64,
|
|
126
|
+
"spotify_track_uri",
|
|
127
|
+
index,
|
|
128
|
+
);
|
|
129
|
+
const uriMatch = trackUriPattern.exec(uri);
|
|
130
|
+
if (!uriMatch) {
|
|
131
|
+
fail(
|
|
132
|
+
`Spotify Extended History record ${index + 1} has an invalid spotify_track_uri.`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
const occurredAt = timestamp(
|
|
136
|
+
record.ts,
|
|
137
|
+
`Spotify Extended History record ${index + 1} timestamp`,
|
|
138
|
+
);
|
|
139
|
+
if (Date.parse(occurredAt) > capturedMilliseconds) {
|
|
140
|
+
fail(`Spotify Extended History record ${index + 1} is later than the import time.`);
|
|
141
|
+
}
|
|
142
|
+
if (!Number.isSafeInteger(record.ms_played) || record.ms_played < 0) {
|
|
143
|
+
fail(`Spotify Extended History record ${index + 1} has an invalid ms_played.`);
|
|
144
|
+
}
|
|
145
|
+
const title = cleanText(
|
|
146
|
+
record.master_metadata_track_name,
|
|
147
|
+
512,
|
|
148
|
+
"master_metadata_track_name",
|
|
149
|
+
index,
|
|
150
|
+
);
|
|
151
|
+
const artist = cleanText(
|
|
152
|
+
record.master_metadata_album_artist_name,
|
|
153
|
+
512,
|
|
154
|
+
"master_metadata_album_artist_name",
|
|
155
|
+
index,
|
|
156
|
+
);
|
|
157
|
+
const album = optionalText(
|
|
158
|
+
record.master_metadata_album_album_name,
|
|
159
|
+
512,
|
|
160
|
+
"master_metadata_album_album_name",
|
|
161
|
+
index,
|
|
162
|
+
);
|
|
163
|
+
const reasonStart = optionalText(
|
|
164
|
+
record.reason_start,
|
|
165
|
+
128,
|
|
166
|
+
"reason_start",
|
|
167
|
+
index,
|
|
168
|
+
);
|
|
169
|
+
const reasonEnd = optionalText(
|
|
170
|
+
record.reason_end,
|
|
171
|
+
128,
|
|
172
|
+
"reason_end",
|
|
173
|
+
index,
|
|
174
|
+
);
|
|
175
|
+
const shuffle = nullableBoolean(record.shuffle, "shuffle", index);
|
|
176
|
+
const skipped = nullableBoolean(record.skipped, "skipped", index);
|
|
177
|
+
const offline = nullableBoolean(record.offline, "offline", index);
|
|
178
|
+
const incognitoMode = nullableBoolean(
|
|
179
|
+
record.incognito_mode,
|
|
180
|
+
"incognito_mode",
|
|
181
|
+
index,
|
|
182
|
+
);
|
|
183
|
+
const trackId = uriMatch[1];
|
|
184
|
+
const tuple = [
|
|
185
|
+
occurredAt,
|
|
186
|
+
trackId,
|
|
187
|
+
String(record.ms_played),
|
|
188
|
+
reasonStart ?? "",
|
|
189
|
+
reasonEnd ?? "",
|
|
190
|
+
String(shuffle),
|
|
191
|
+
String(skipped),
|
|
192
|
+
String(offline),
|
|
193
|
+
String(incognitoMode),
|
|
194
|
+
].join("\0");
|
|
195
|
+
rows.push({
|
|
196
|
+
occurredAt,
|
|
197
|
+
playedMs: record.ms_played,
|
|
198
|
+
title,
|
|
199
|
+
artist,
|
|
200
|
+
album,
|
|
201
|
+
reasonStart,
|
|
202
|
+
reasonEnd,
|
|
203
|
+
shuffle,
|
|
204
|
+
skipped,
|
|
205
|
+
offline,
|
|
206
|
+
incognitoMode,
|
|
207
|
+
trackId,
|
|
208
|
+
tuple,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
return rows.sort((left, right) => lexicalCompare(left.tuple, right.tuple));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function normalizedMemberNames(memberNames) {
|
|
215
|
+
if (
|
|
216
|
+
!Array.isArray(memberNames) ||
|
|
217
|
+
memberNames.length < 1 ||
|
|
218
|
+
memberNames.length > SPOTIFY_EXTENDED_HISTORY_LIMITS.maximumHistoryMembers
|
|
219
|
+
) {
|
|
220
|
+
fail("Spotify Extended History member names are invalid.");
|
|
221
|
+
}
|
|
222
|
+
const normalized = memberNames.map((value) => {
|
|
223
|
+
if (
|
|
224
|
+
typeof value !== "string" ||
|
|
225
|
+
!/^Streaming_History_Audio_[0-9]{4}(?:_[0-9]+)?\.json$/u.test(value)
|
|
226
|
+
) {
|
|
227
|
+
fail("Spotify Extended History member names are invalid.");
|
|
228
|
+
}
|
|
229
|
+
return value;
|
|
230
|
+
});
|
|
231
|
+
if (new Set(normalized).size !== normalized.length) {
|
|
232
|
+
fail("Spotify Extended History contains duplicate member names.");
|
|
233
|
+
}
|
|
234
|
+
return [...normalized].sort(lexicalCompare);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function projectSpotifyExtendedStreamingHistory({
|
|
238
|
+
subjectId,
|
|
239
|
+
records,
|
|
240
|
+
archiveSha256,
|
|
241
|
+
archiveSizeBytes,
|
|
242
|
+
memberNames,
|
|
243
|
+
capturedAt = new Date().toISOString(),
|
|
244
|
+
} = {}) {
|
|
245
|
+
if (!isUuid(subjectId)) fail("A trusted subject ID is required.");
|
|
246
|
+
const normalizedSubjectId = subjectId.toLowerCase();
|
|
247
|
+
const normalizedCapturedAt = timestamp(
|
|
248
|
+
capturedAt,
|
|
249
|
+
"Spotify Extended History import timestamp",
|
|
250
|
+
);
|
|
251
|
+
if (typeof archiveSha256 !== "string" || !sha256Pattern.test(archiveSha256)) {
|
|
252
|
+
fail("Spotify Extended History archive SHA-256 is invalid.");
|
|
253
|
+
}
|
|
254
|
+
if (
|
|
255
|
+
!Number.isSafeInteger(archiveSizeBytes) ||
|
|
256
|
+
archiveSizeBytes < 1 ||
|
|
257
|
+
archiveSizeBytes > SPOTIFY_EXTENDED_HISTORY_LIMITS.maximumArchiveBytes
|
|
258
|
+
) {
|
|
259
|
+
fail("Spotify Extended History archive size is invalid.");
|
|
260
|
+
}
|
|
261
|
+
const normalizedMembers = normalizedMemberNames(memberNames);
|
|
262
|
+
const rows = normalizedMusicRows(records, normalizedCapturedAt);
|
|
263
|
+
const importBatchId = uuidV5(
|
|
264
|
+
`${normalizedSubjectId}\0${archiveSha256}`,
|
|
265
|
+
SPOTIFY_EXTENDED_HISTORY_BATCH_NAMESPACE,
|
|
266
|
+
);
|
|
267
|
+
const trackRefs = new Map();
|
|
268
|
+
const listeningEvents = [];
|
|
269
|
+
const reconciliationCandidates = [];
|
|
270
|
+
const eventOrdinals = new Map();
|
|
271
|
+
const standardOrdinals = new Map();
|
|
272
|
+
|
|
273
|
+
for (const row of rows) {
|
|
274
|
+
const trackRefId = uuidV5(row.trackId, SPOTIFY_TRACK_NAMESPACE);
|
|
275
|
+
if (!trackRefs.has(trackRefId)) {
|
|
276
|
+
const trackRef = {
|
|
277
|
+
schema_version: 1,
|
|
278
|
+
track_ref_id: trackRefId,
|
|
279
|
+
revision: 1,
|
|
280
|
+
identity_status: "resolved",
|
|
281
|
+
display_label: clippedText(`${row.title} - ${row.artist}`),
|
|
282
|
+
title: row.title,
|
|
283
|
+
artist_credits: [{ name: row.artist, role: "unknown" }],
|
|
284
|
+
external_refs: [
|
|
285
|
+
{
|
|
286
|
+
system: "spotify",
|
|
287
|
+
entity_type: "spotify.track",
|
|
288
|
+
external_id: row.trackId,
|
|
289
|
+
},
|
|
290
|
+
],
|
|
291
|
+
created_at: row.occurredAt,
|
|
292
|
+
resolved_at: row.occurredAt,
|
|
293
|
+
};
|
|
294
|
+
if (row.album) trackRef.release = { title: row.album };
|
|
295
|
+
trackRefs.set(trackRefId, trackRef);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const duplicateOrdinal = (eventOrdinals.get(row.tuple) ?? 0) + 1;
|
|
299
|
+
eventOrdinals.set(row.tuple, duplicateOrdinal);
|
|
300
|
+
const eventFingerprint = hashParts(
|
|
301
|
+
"spotify-extended-history-play-v1",
|
|
302
|
+
normalizedSubjectId,
|
|
303
|
+
row.tuple,
|
|
304
|
+
duplicateOrdinal,
|
|
305
|
+
);
|
|
306
|
+
const listeningEventId = uuidV5(
|
|
307
|
+
eventFingerprint,
|
|
308
|
+
SPOTIFY_EXTENDED_HISTORY_EVENT_NAMESPACE,
|
|
309
|
+
);
|
|
310
|
+
const extensions = {};
|
|
311
|
+
if (row.reasonStart !== null) {
|
|
312
|
+
extensions["spotify.reason_start"] = row.reasonStart;
|
|
313
|
+
}
|
|
314
|
+
if (row.reasonEnd !== null) {
|
|
315
|
+
extensions["spotify.reason_end"] = row.reasonEnd;
|
|
316
|
+
}
|
|
317
|
+
if (row.shuffle !== null) extensions["spotify.shuffle"] = row.shuffle;
|
|
318
|
+
if (row.skipped !== null) extensions["spotify.skipped"] = row.skipped;
|
|
319
|
+
if (row.offline !== null) extensions["spotify.offline"] = row.offline;
|
|
320
|
+
if (row.incognitoMode !== null) {
|
|
321
|
+
extensions["spotify.incognito_mode"] = row.incognitoMode;
|
|
322
|
+
}
|
|
323
|
+
const event = {
|
|
324
|
+
schema_version: 1,
|
|
325
|
+
listening_event_id: listeningEventId,
|
|
326
|
+
subject_id: normalizedSubjectId,
|
|
327
|
+
track_ref_id: trackRefId,
|
|
328
|
+
track_ref_revision: 1,
|
|
329
|
+
event_type: row.skipped === true ? "play_skipped" : "play_observed",
|
|
330
|
+
occurred_at: row.occurredAt,
|
|
331
|
+
ingested_at: normalizedCapturedAt,
|
|
332
|
+
interaction_mode: "unknown",
|
|
333
|
+
played_ms: row.playedMs,
|
|
334
|
+
context: { context_type: "unknown" },
|
|
335
|
+
provenance: {
|
|
336
|
+
source_kind: "import",
|
|
337
|
+
captured_at: normalizedCapturedAt,
|
|
338
|
+
external_ref: {
|
|
339
|
+
system: "spotify-extended-history",
|
|
340
|
+
entity_type: "spotify.extended_history.play",
|
|
341
|
+
external_id: eventFingerprint,
|
|
342
|
+
},
|
|
343
|
+
import_batch_id: importBatchId,
|
|
344
|
+
},
|
|
345
|
+
deduplication_key: eventFingerprint,
|
|
346
|
+
};
|
|
347
|
+
if (Object.keys(extensions).length > 0) event.extensions = extensions;
|
|
348
|
+
listeningEvents.push(event);
|
|
349
|
+
|
|
350
|
+
const standardTime = standardOccurredAt(row.occurredAt);
|
|
351
|
+
const standardTuple = [
|
|
352
|
+
standardTime,
|
|
353
|
+
row.artist.normalize("NFKC").toLowerCase(),
|
|
354
|
+
row.title.normalize("NFKC").toLowerCase(),
|
|
355
|
+
String(row.playedMs),
|
|
356
|
+
].join("\0");
|
|
357
|
+
const standardOrdinal = (standardOrdinals.get(standardTuple) ?? 0) + 1;
|
|
358
|
+
standardOrdinals.set(standardTuple, standardOrdinal);
|
|
359
|
+
const standardIdentity = spotifyAccountDataEventIdentity({
|
|
360
|
+
subjectId: normalizedSubjectId,
|
|
361
|
+
occurredAt: standardTime,
|
|
362
|
+
artist: row.artist,
|
|
363
|
+
title: row.title,
|
|
364
|
+
playedMs: row.playedMs,
|
|
365
|
+
duplicateOrdinal: standardOrdinal,
|
|
366
|
+
});
|
|
367
|
+
reconciliationCandidates.push({
|
|
368
|
+
superseding_event_id: listeningEventId,
|
|
369
|
+
superseded_event_id: standardIdentity.listening_event_id,
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const earliestOccurredAt = rows[0]?.occurredAt ?? null;
|
|
374
|
+
const latestOccurredAt = rows.at(-1)?.occurredAt ?? null;
|
|
375
|
+
return {
|
|
376
|
+
source_key: SPOTIFY_EXTENDED_HISTORY_SOURCE,
|
|
377
|
+
captured_at: normalizedCapturedAt,
|
|
378
|
+
cursor_after_ms:
|
|
379
|
+
latestOccurredAt === null ? null : Date.parse(latestOccurredAt),
|
|
380
|
+
track_refs: [...trackRefs.values()],
|
|
381
|
+
listening_events: listeningEvents,
|
|
382
|
+
reconciliation_candidates: reconciliationCandidates,
|
|
383
|
+
import_batch: {
|
|
384
|
+
import_batch_id: importBatchId,
|
|
385
|
+
subject_id: normalizedSubjectId,
|
|
386
|
+
source_format: SPOTIFY_EXTENDED_HISTORY_FORMAT,
|
|
387
|
+
data_scope: SPOTIFY_EXTENDED_HISTORY_SCOPE,
|
|
388
|
+
archive_sha256: archiveSha256,
|
|
389
|
+
archive_size_bytes: archiveSizeBytes,
|
|
390
|
+
member_names: normalizedMembers,
|
|
391
|
+
input_records: rows.length,
|
|
392
|
+
earliest_occurred_at: earliestOccurredAt,
|
|
393
|
+
latest_occurred_at: latestOccurredAt,
|
|
394
|
+
imported_at: normalizedCapturedAt,
|
|
395
|
+
},
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async function sha256File(filePath) {
|
|
400
|
+
const hash = createHash("sha256");
|
|
401
|
+
for await (const chunk of createReadStream(filePath)) hash.update(chunk);
|
|
402
|
+
return hash.digest("hex");
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
async function unzip(args, maximumBytes) {
|
|
406
|
+
try {
|
|
407
|
+
const { stdout } = await execFileAsync("/usr/bin/unzip", args, {
|
|
408
|
+
encoding: "utf8",
|
|
409
|
+
maxBuffer: maximumBytes,
|
|
410
|
+
windowsHide: true,
|
|
411
|
+
});
|
|
412
|
+
return stdout;
|
|
413
|
+
} catch {
|
|
414
|
+
fail(
|
|
415
|
+
"The Spotify Extended History ZIP could not be read safely.",
|
|
416
|
+
"spotify_extended_history_archive_unreadable",
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function selectedAudioMembers(listing) {
|
|
422
|
+
const selected = listing
|
|
423
|
+
.split(/\r?\n/u)
|
|
424
|
+
.filter(Boolean)
|
|
425
|
+
.map((name) => {
|
|
426
|
+
const match = audioMemberPattern.exec(name);
|
|
427
|
+
return match
|
|
428
|
+
? {
|
|
429
|
+
name,
|
|
430
|
+
year: Number(match[1]),
|
|
431
|
+
segment: match[2] === undefined ? 0 : Number(match[2]) + 1,
|
|
432
|
+
}
|
|
433
|
+
: null;
|
|
434
|
+
})
|
|
435
|
+
.filter(Boolean)
|
|
436
|
+
.sort((left, right) =>
|
|
437
|
+
left.year === right.year
|
|
438
|
+
? left.segment - right.segment
|
|
439
|
+
: left.year - right.year,
|
|
440
|
+
);
|
|
441
|
+
if (selected.length < 1) {
|
|
442
|
+
fail("The ZIP does not contain Spotify Extended History audio files.");
|
|
443
|
+
}
|
|
444
|
+
if (selected.length > SPOTIFY_EXTENDED_HISTORY_LIMITS.maximumHistoryMembers) {
|
|
445
|
+
fail("The ZIP contains too many Spotify Extended History audio files.");
|
|
446
|
+
}
|
|
447
|
+
if (new Set(selected.map(({ name }) => name)).size !== selected.length) {
|
|
448
|
+
fail("The ZIP contains duplicate Spotify Extended History audio files.");
|
|
449
|
+
}
|
|
450
|
+
return selected;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
export async function readSpotifyExtendedStreamingHistoryArchive({
|
|
454
|
+
archivePath,
|
|
455
|
+
subjectId,
|
|
456
|
+
capturedAt = new Date().toISOString(),
|
|
457
|
+
} = {}) {
|
|
458
|
+
if (typeof archivePath !== "string" || !archivePath.trim()) {
|
|
459
|
+
fail("A Spotify Extended History ZIP path is required.");
|
|
460
|
+
}
|
|
461
|
+
const resolvedPath = path.resolve(archivePath);
|
|
462
|
+
if (path.extname(resolvedPath).toLowerCase() !== ".zip") {
|
|
463
|
+
fail("Spotify Extended History must be supplied as a ZIP archive.");
|
|
464
|
+
}
|
|
465
|
+
let file;
|
|
466
|
+
try {
|
|
467
|
+
file = await lstat(resolvedPath);
|
|
468
|
+
} catch {
|
|
469
|
+
fail(
|
|
470
|
+
"The Spotify Extended History ZIP could not be opened.",
|
|
471
|
+
"spotify_extended_history_archive_unreadable",
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
if (
|
|
475
|
+
!file.isFile() ||
|
|
476
|
+
file.isSymbolicLink() ||
|
|
477
|
+
file.size < 1 ||
|
|
478
|
+
file.size > SPOTIFY_EXTENDED_HISTORY_LIMITS.maximumArchiveBytes
|
|
479
|
+
) {
|
|
480
|
+
fail("The Spotify Extended History ZIP is not a supported regular file.");
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const listing = await unzip(["-Z1", resolvedPath], 1024 * 1024);
|
|
484
|
+
const members = selectedAudioMembers(listing);
|
|
485
|
+
const records = [];
|
|
486
|
+
let totalJsonBytes = 0;
|
|
487
|
+
for (const member of members) {
|
|
488
|
+
const jsonText = await unzip(
|
|
489
|
+
["-p", resolvedPath, member.name],
|
|
490
|
+
SPOTIFY_EXTENDED_HISTORY_LIMITS.maximumMemberBytes,
|
|
491
|
+
);
|
|
492
|
+
totalJsonBytes += Buffer.byteLength(jsonText, "utf8");
|
|
493
|
+
if (
|
|
494
|
+
totalJsonBytes > SPOTIFY_EXTENDED_HISTORY_LIMITS.maximumTotalJsonBytes
|
|
495
|
+
) {
|
|
496
|
+
fail("Spotify Extended History JSON is too large to import safely.");
|
|
497
|
+
}
|
|
498
|
+
let parsed;
|
|
499
|
+
try {
|
|
500
|
+
parsed = JSON.parse(jsonText.replace(/^\uFEFF/u, ""));
|
|
501
|
+
} catch {
|
|
502
|
+
fail(
|
|
503
|
+
`Spotify Extended History audio file for ${member.year} is invalid JSON.`,
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
if (!Array.isArray(parsed)) {
|
|
507
|
+
fail(
|
|
508
|
+
`Spotify Extended History audio file for ${member.year} is not an array.`,
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
records.push(...parsed);
|
|
512
|
+
if (records.length > SPOTIFY_EXTENDED_HISTORY_LIMITS.maximumRecords) {
|
|
513
|
+
fail("Spotify Extended History contains too many records.");
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
return projectSpotifyExtendedStreamingHistory({
|
|
518
|
+
subjectId,
|
|
519
|
+
records,
|
|
520
|
+
archiveSha256: await sha256File(resolvedPath),
|
|
521
|
+
archiveSizeBytes: file.size,
|
|
522
|
+
memberNames: members.map(({ name }) => path.posix.basename(name)),
|
|
523
|
+
capturedAt,
|
|
524
|
+
});
|
|
525
|
+
}
|