@audiofool/moondog 0.1.0 → 0.2.0-beta.2
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 +153 -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 +168 -36
- package/src/core/application-update.mjs +320 -0
- package/src/core/capability-catalog.mjs +278 -55
- package/src/core/listening-now.mjs +108 -0
- package/src/core/listening-profile-domain-services.mjs +153 -41
- package/src/core/listening-upkeep.mjs +31 -0
- package/src/core/moondog-application.mjs +1908 -104
- package/src/core/playback-preferences.mjs +97 -0
- package/src/core/spotify-listening-intent.mjs +121 -0
- package/src/core/synthetic-domain-services.mjs +24 -3
- package/src/demo/moondog-demo.mjs +4 -22
- package/src/i18n/index.mjs +285 -0
- package/src/i18n/messages/de.mjs +128 -0
- package/src/i18n/messages/en.mjs +129 -0
- package/src/i18n/messages/es.mjs +128 -0
- package/src/i18n/messages/fr.mjs +128 -0
- package/src/i18n/messages/it.mjs +128 -0
- package/src/i18n/messages/ja.mjs +128 -0
- package/src/i18n/messages/ko.mjs +128 -0
- package/src/i18n/messages/pt.mjs +128 -0
- package/src/i18n/messages/zh-Hant.mjs +128 -0
- package/src/i18n/messages/zh.mjs +128 -0
- package/src/i18n/preferences.mjs +34 -0
- package/src/i18n/screen/es.mjs +966 -0
- package/src/i18n/screen/ja.mjs +966 -0
- package/src/i18n/screen/pt.mjs +966 -0
- package/src/i18n/screen/zh.mjs +966 -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 +123 -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 +321 -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 +8 -4
- 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 +407 -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 +469 -41
- package/src/profile/history-import.mjs +42 -6
- package/src/profile/listener-profile-build.mjs +378 -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 +172 -0
- package/src/profile/listening-history-store.mjs +424 -8
- package/src/profile/listening-profile-projection.mjs +242 -76
- package/src/profile/local-music-data.mjs +1 -1
- package/src/profile/music-providers.mjs +11 -7
- package/src/profile/profile-exploration.mjs +84 -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 +2204 -605
- 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 +160 -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 +46 -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
|
@@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
|
|
|
2
2
|
import {
|
|
3
3
|
createHash,
|
|
4
4
|
randomBytes,
|
|
5
|
+
randomUUID,
|
|
5
6
|
timingSafeEqual,
|
|
6
7
|
} from "node:crypto";
|
|
7
8
|
import { createServer } from "node:http";
|
|
@@ -21,9 +22,12 @@ export const SPOTIFY_DEFAULT_SCOPES = Object.freeze([
|
|
|
21
22
|
"user-modify-playback-state",
|
|
22
23
|
"playlist-read-private",
|
|
23
24
|
"playlist-modify-private",
|
|
25
|
+
"playlist-modify-public",
|
|
24
26
|
"user-library-read",
|
|
25
27
|
"user-library-modify",
|
|
26
28
|
"user-read-recently-played",
|
|
29
|
+
"user-top-read",
|
|
30
|
+
"user-follow-read",
|
|
27
31
|
]);
|
|
28
32
|
|
|
29
33
|
export class SpotifyAuthenticationError extends Error {
|
|
@@ -240,7 +244,12 @@ function validateStoredCredential(value) {
|
|
|
240
244
|
value.refreshExpiresAt <= 0 ||
|
|
241
245
|
value.tokenType !== "Bearer" ||
|
|
242
246
|
typeof value.scope !== "string" ||
|
|
243
|
-
value.scope.length === 0
|
|
247
|
+
value.scope.length === 0 ||
|
|
248
|
+
(value.refreshAttempt !== undefined && (
|
|
249
|
+
!value.refreshAttempt ||
|
|
250
|
+
!/^[\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12}$/u.test(value.refreshAttempt.id) ||
|
|
251
|
+
!Number.isSafeInteger(value.refreshAttempt.startedAt) || value.refreshAttempt.startedAt <= 0
|
|
252
|
+
))
|
|
244
253
|
) {
|
|
245
254
|
throw safeError(
|
|
246
255
|
"spotify_credential_invalid",
|
|
@@ -643,7 +652,11 @@ export function createSpotifyAuthentication({
|
|
|
643
652
|
redirectUri = SPOTIFY_DEFAULT_REDIRECT_URI,
|
|
644
653
|
scopes = SPOTIFY_DEFAULT_SCOPES,
|
|
645
654
|
now = Date.now,
|
|
655
|
+
refreshTimeoutMs = 15_000,
|
|
646
656
|
} = {}) {
|
|
657
|
+
if (!Number.isSafeInteger(refreshTimeoutMs) || refreshTimeoutMs < 1 || refreshTimeoutMs > 30_000) {
|
|
658
|
+
throw safeError("spotify_refresh_timeout_invalid", "Spotify refresh timeout must be between 1 and 30000 ms.");
|
|
659
|
+
}
|
|
647
660
|
assertNonEmptyString(
|
|
648
661
|
clientId,
|
|
649
662
|
"spotify_client_id_invalid",
|
|
@@ -664,24 +677,37 @@ export function createSpotifyAuthentication({
|
|
|
664
677
|
}
|
|
665
678
|
const redirect = normalizeRedirectUri(redirectUri);
|
|
666
679
|
const requestedScopes = normalizeScopes(scopes);
|
|
680
|
+
let generation = 0;
|
|
681
|
+
let refreshTask = null;
|
|
682
|
+
let mutationTail = Promise.resolve();
|
|
683
|
+
const mutateCredential = (operation) => {
|
|
684
|
+
const result = mutationTail.then(operation);
|
|
685
|
+
mutationTail = result.catch(() => {});
|
|
686
|
+
return result;
|
|
687
|
+
};
|
|
667
688
|
|
|
668
689
|
const readCredential = async (options = {}) => {
|
|
690
|
+
assertNotAborted(options.signal);
|
|
669
691
|
let value;
|
|
670
692
|
try {
|
|
671
693
|
value = await credentialStore.read(options);
|
|
672
694
|
} catch {
|
|
695
|
+
assertNotAborted(options.signal);
|
|
673
696
|
throw safeError(
|
|
674
697
|
"spotify_credential_store_failed",
|
|
675
698
|
"Spotify credential storage could not be read.",
|
|
676
699
|
);
|
|
677
700
|
}
|
|
701
|
+
assertNotAborted(options.signal);
|
|
678
702
|
return value === undefined ? undefined : validateStoredCredential(value);
|
|
679
703
|
};
|
|
680
704
|
|
|
681
705
|
const writeCredential = async (credential, options = {}) => {
|
|
706
|
+
assertNotAborted(options.signal);
|
|
682
707
|
try {
|
|
683
708
|
await credentialStore.write(structuredClone(credential), options);
|
|
684
709
|
} catch {
|
|
710
|
+
assertNotAborted(options.signal);
|
|
685
711
|
throw safeError(
|
|
686
712
|
"spotify_credential_store_failed",
|
|
687
713
|
"Spotify credential storage could not be updated.",
|
|
@@ -689,9 +715,115 @@ export function createSpotifyAuthentication({
|
|
|
689
715
|
}
|
|
690
716
|
};
|
|
691
717
|
|
|
718
|
+
// The timer bounds lock acquisition and the token exchange. A token service
|
|
719
|
+
// may ignore abort: race only its response, never the locked transaction.
|
|
720
|
+
const waitForExchange = (promise, signal) => new Promise((resolve, reject) => {
|
|
721
|
+
const onAbort = () => reject(signal.reason ?? abortError());
|
|
722
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
723
|
+
Promise.resolve(promise).then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
|
|
724
|
+
if (signal.aborted) onAbort();
|
|
725
|
+
});
|
|
726
|
+
const credentialMatches = (a, b) => ["type", "accessToken", "refreshToken", "accessExpiresAt", "refreshExpiresAt", "tokenType", "scope"]
|
|
727
|
+
.every((key) => a?.[key] === b?.[key]);
|
|
728
|
+
|
|
729
|
+
const forceRefreshAccessToken = async ({ signal, rejectedAccessToken, proactive = false } = {}) => {
|
|
730
|
+
assertNotAborted(signal);
|
|
731
|
+
const credential = await readCredential({ signal });
|
|
732
|
+
if (!credential) throw safeError("spotify_auth_not_configured", "Spotify is not authenticated.");
|
|
733
|
+
const usable = (value) => value.accessExpiresAt > now() + ACCESS_TOKEN_REFRESH_WINDOW_MS;
|
|
734
|
+
if (!credential.refreshAttempt && usable(credential) &&
|
|
735
|
+
((rejectedAccessToken && rejectedAccessToken !== credential.accessToken) || proactive)) {
|
|
736
|
+
return credential.accessToken;
|
|
737
|
+
}
|
|
738
|
+
if (typeof credentialStore.modify !== "function") {
|
|
739
|
+
throw safeError("spotify_refresh_coordination_unavailable", "Spotify refresh requires coordinated credential storage. Run /spotify login with the supported credential store.");
|
|
740
|
+
}
|
|
741
|
+
if (refreshTask && (refreshTask.controller.signal.aborted || refreshTask.generation !== generation)) refreshTask = null;
|
|
742
|
+
if (!refreshTask) {
|
|
743
|
+
const task = { controller: new AbortController(), waiters: 0, generation, started: false, credential, rejectedAccessToken };
|
|
744
|
+
const sharedSignal = task.controller.signal;
|
|
745
|
+
const timer = setTimeout(() => task.controller.abort(safeError(
|
|
746
|
+
"spotify_refresh_timeout", "Spotify refresh timed out. Run /spotify login if the refresh was interrupted.",
|
|
747
|
+
)), refreshTimeoutMs);
|
|
748
|
+
task.promise = mutateCredential(async () => {
|
|
749
|
+
try {
|
|
750
|
+
const stored = await credentialStore.modify(async (value, transaction) => {
|
|
751
|
+
assertNotAborted(sharedSignal);
|
|
752
|
+
if (!value) throw safeError("spotify_auth_not_configured", "Spotify is not authenticated.");
|
|
753
|
+
const current = validateStoredCredential(value);
|
|
754
|
+
// This check precedes *all* token reuse. A durable marker left
|
|
755
|
+
// after crash/timeout fences every process until explicit login.
|
|
756
|
+
if (current.refreshAttempt) {
|
|
757
|
+
throw safeError("spotify_refresh_uncertain", "A previous Spotify refresh was interrupted. Run /spotify login to authorize again; no refresh was retried.");
|
|
758
|
+
}
|
|
759
|
+
const mayReuse = rejectedAccessToken
|
|
760
|
+
? current.accessToken !== rejectedAccessToken
|
|
761
|
+
: proactive || !credentialMatches(current, credential);
|
|
762
|
+
if (usable(current) && mayReuse) return undefined;
|
|
763
|
+
if (generation !== task.generation) throw safeError("spotify_auth_changed", "Spotify authentication changed during refresh.");
|
|
764
|
+
if (typeof transaction?.checkpoint !== "function") {
|
|
765
|
+
throw safeError("spotify_refresh_coordination_unavailable", "Spotify refresh requires durable coordinated credential storage.");
|
|
766
|
+
}
|
|
767
|
+
// Persist intent before dispatch. Do not abandon rotation merely
|
|
768
|
+
// because the last caller stops waiting: drain under the deadline.
|
|
769
|
+
task.started = true;
|
|
770
|
+
await transaction.checkpoint({ ...current, refreshAttempt: { id: randomUUID(), startedAt: now() } });
|
|
771
|
+
assertNotAborted(sharedSignal);
|
|
772
|
+
const refreshed = await waitForExchange(refreshSpotifyAccessToken({
|
|
773
|
+
clientId, refreshToken: current.refreshToken,
|
|
774
|
+
refreshExpiresAt: current.refreshExpiresAt, scopes: current.scope,
|
|
775
|
+
fetchImpl, signal: sharedSignal, now,
|
|
776
|
+
}), sharedSignal);
|
|
777
|
+
assertNotAborted(sharedSignal);
|
|
778
|
+
if (generation !== task.generation) throw safeError("spotify_auth_changed", "Spotify authentication changed during refresh.");
|
|
779
|
+
return refreshed;
|
|
780
|
+
}, { signal: sharedSignal });
|
|
781
|
+
return validateStoredCredential(stored).accessToken;
|
|
782
|
+
} catch (error) {
|
|
783
|
+
if (sharedSignal.aborted) throw sharedSignal.reason ?? abortError();
|
|
784
|
+
if (error instanceof SpotifyAuthenticationError) throw error;
|
|
785
|
+
throw safeError("spotify_credential_store_failed", "Spotify credential refresh could not be persisted. Run /spotify login if the refresh was interrupted.");
|
|
786
|
+
}
|
|
787
|
+
}).finally(() => { clearTimeout(timer); if (refreshTask === task) refreshTask = null; });
|
|
788
|
+
refreshTask = task;
|
|
789
|
+
}
|
|
790
|
+
const task = refreshTask;
|
|
791
|
+
task.waiters += 1;
|
|
792
|
+
return new Promise((resolve, reject) => {
|
|
793
|
+
let finished = false;
|
|
794
|
+
const finish = (error, value) => {
|
|
795
|
+
if (finished) return;
|
|
796
|
+
finished = true;
|
|
797
|
+
signal?.removeEventListener("abort", onAbort);
|
|
798
|
+
task.controller.signal.removeEventListener("abort", onSharedAbort);
|
|
799
|
+
task.waiters -= 1;
|
|
800
|
+
if (error) reject(error); else resolve(value);
|
|
801
|
+
};
|
|
802
|
+
const onAbort = () => {
|
|
803
|
+
finish(abortError());
|
|
804
|
+
if (task.waiters === 0 && !task.started) task.controller.abort(abortError());
|
|
805
|
+
};
|
|
806
|
+
const onSharedAbort = () => finish(task.controller.signal.reason ?? abortError());
|
|
807
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
808
|
+
task.controller.signal.addEventListener("abort", onSharedAbort, { once: true });
|
|
809
|
+
task.promise.then((value) => {
|
|
810
|
+
// A caller may have observed a newer token while this task waited for
|
|
811
|
+
// the lock. Do not satisfy its rejected-token refresh with that token.
|
|
812
|
+
if (!finished && value === rejectedAccessToken &&
|
|
813
|
+
task.credential.accessToken !== rejectedAccessToken && task.rejectedAccessToken !== rejectedAccessToken) {
|
|
814
|
+
finish(null, forceRefreshAccessToken({ signal, rejectedAccessToken, proactive }));
|
|
815
|
+
} else finish(null, value);
|
|
816
|
+
}, (error) => finish(error));
|
|
817
|
+
if (signal?.aborted) onAbort();
|
|
818
|
+
if (task.controller.signal.aborted) onSharedAbort();
|
|
819
|
+
});
|
|
820
|
+
};
|
|
821
|
+
|
|
692
822
|
return {
|
|
693
823
|
async login({ signal, onAuthorizationUrl } = {}) {
|
|
694
824
|
assertNotAborted(signal);
|
|
825
|
+
const loginGeneration = ++generation;
|
|
826
|
+
refreshTask?.controller.abort();
|
|
695
827
|
if (
|
|
696
828
|
onAuthorizationUrl !== undefined &&
|
|
697
829
|
typeof onAuthorizationUrl !== "function"
|
|
@@ -738,7 +870,12 @@ export function createSpotifyAuthentication({
|
|
|
738
870
|
signal,
|
|
739
871
|
now,
|
|
740
872
|
});
|
|
741
|
-
await
|
|
873
|
+
await mutateCredential(async () => {
|
|
874
|
+
if (generation !== loginGeneration) {
|
|
875
|
+
throw safeError("spotify_auth_changed", "Spotify authentication changed during login.");
|
|
876
|
+
}
|
|
877
|
+
await writeCredential(credential, { signal });
|
|
878
|
+
});
|
|
742
879
|
return publicStatus(credential);
|
|
743
880
|
} finally {
|
|
744
881
|
await listener.close();
|
|
@@ -759,26 +896,23 @@ export function createSpotifyAuthentication({
|
|
|
759
896
|
"Spotify is not authenticated.",
|
|
760
897
|
);
|
|
761
898
|
}
|
|
762
|
-
if (credential.accessExpiresAt > now() + ACCESS_TOKEN_REFRESH_WINDOW_MS) {
|
|
899
|
+
if (!credential.refreshAttempt && credential.accessExpiresAt > now() + ACCESS_TOKEN_REFRESH_WINDOW_MS) {
|
|
763
900
|
return credential.accessToken;
|
|
764
901
|
}
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
signal,
|
|
772
|
-
now,
|
|
773
|
-
});
|
|
774
|
-
await writeCredential(refreshed, { signal });
|
|
775
|
-
return refreshed.accessToken;
|
|
902
|
+
return forceRefreshAccessToken({ signal, proactive: true });
|
|
903
|
+
},
|
|
904
|
+
|
|
905
|
+
// A late 401 for an old token reuses the refresh that already replaced it.
|
|
906
|
+
async refreshAccessToken({ signal, rejectedAccessToken } = {}) {
|
|
907
|
+
return forceRefreshAccessToken({ signal, rejectedAccessToken });
|
|
776
908
|
},
|
|
777
909
|
|
|
778
910
|
async logout({ signal } = {}) {
|
|
779
911
|
assertNotAborted(signal);
|
|
912
|
+
generation += 1;
|
|
913
|
+
refreshTask?.controller.abort();
|
|
780
914
|
try {
|
|
781
|
-
await credentialStore.delete({ signal });
|
|
915
|
+
await mutateCredential(() => credentialStore.delete({ signal }));
|
|
782
916
|
} catch {
|
|
783
917
|
throw safeError(
|
|
784
918
|
"spotify_credential_store_failed",
|
|
@@ -523,17 +523,18 @@ export function createSpotifyCatalogResolver({ client, cache = null, now = Date.
|
|
|
523
523
|
throw new TypeError("The Spotify resolution cache interface is invalid.");
|
|
524
524
|
}
|
|
525
525
|
|
|
526
|
-
async function searchAndMatch(track) {
|
|
526
|
+
async function searchAndMatch(track, { signal } = {}) {
|
|
527
527
|
const queries = [
|
|
528
528
|
fieldedSearchQuery(track),
|
|
529
529
|
plainSearchQuery(track),
|
|
530
530
|
];
|
|
531
531
|
let fallback = null;
|
|
532
532
|
for (const query of queries) {
|
|
533
|
+
signal?.throwIfAborted();
|
|
533
534
|
const result = await client.searchTracks({
|
|
534
535
|
query,
|
|
535
536
|
limit: SPOTIFY_CATALOG_RESOLVER_LIMITS.searchResultsMax,
|
|
536
|
-
});
|
|
537
|
+
}, { signal });
|
|
537
538
|
const items = Array.isArray(result?.items) ? result.items : [];
|
|
538
539
|
const match = bestCandidate(track, items);
|
|
539
540
|
if (!match) continue;
|
|
@@ -554,7 +555,8 @@ export function createSpotifyCatalogResolver({ client, cache = null, now = Date.
|
|
|
554
555
|
}
|
|
555
556
|
|
|
556
557
|
return Object.freeze({
|
|
557
|
-
async resolve(tracks) {
|
|
558
|
+
async resolve(tracks, { signal } = {}) {
|
|
559
|
+
signal?.throwIfAborted();
|
|
558
560
|
if (!Array.isArray(tracks) || tracks.length < 1) {
|
|
559
561
|
fail("invalid_tracks", "At least one track is required for resolution.");
|
|
560
562
|
}
|
|
@@ -626,7 +628,8 @@ export function createSpotifyCatalogResolver({ client, cache = null, now = Date.
|
|
|
626
628
|
}
|
|
627
629
|
let entry = cache ? await cache.get(cacheKey) : null;
|
|
628
630
|
if (!entry) {
|
|
629
|
-
entry = await searchAndMatch(track);
|
|
631
|
+
entry = await searchAndMatch(track, { signal });
|
|
632
|
+
signal?.throwIfAborted();
|
|
630
633
|
if (entry.status === "resolved" && cache) {
|
|
631
634
|
await cache.put(cacheKey, entry, now());
|
|
632
635
|
}
|
|
@@ -635,6 +638,7 @@ export function createSpotifyCatalogResolver({ client, cache = null, now = Date.
|
|
|
635
638
|
}),
|
|
636
639
|
);
|
|
637
640
|
|
|
641
|
+
signal?.throwIfAborted();
|
|
638
642
|
const resolutions = prepared.map((track) => {
|
|
639
643
|
const entry = entriesByKey.get(resolutionKey(track));
|
|
640
644
|
return {
|
|
@@ -79,7 +79,8 @@ export async function openSpotifyConnection({
|
|
|
79
79
|
});
|
|
80
80
|
const client = createSpotifyWebApiClient({
|
|
81
81
|
fetchImpl,
|
|
82
|
-
tokenProvider: () => authentication.getAccessToken(),
|
|
82
|
+
tokenProvider: (options) => authentication.getAccessToken(options),
|
|
83
|
+
refreshAccessToken: (options) => authentication.refreshAccessToken(options),
|
|
83
84
|
});
|
|
84
85
|
const service = createSpotifyService({ client });
|
|
85
86
|
const resolver = createSpotifyCatalogResolver({
|
|
@@ -11,6 +11,10 @@ export function createSpotifyCredentialStore({
|
|
|
11
11
|
return credentialStore.read(providerId, options);
|
|
12
12
|
},
|
|
13
13
|
|
|
14
|
+
async modify(operation, options) {
|
|
15
|
+
return credentialStore.modify(providerId, operation, options);
|
|
16
|
+
},
|
|
17
|
+
|
|
14
18
|
async write(credential, options) {
|
|
15
19
|
return credentialStore.modify(
|
|
16
20
|
providerId,
|
|
@@ -31,8 +31,8 @@ export const SPOTIFY_EXTENDED_HISTORY_LIMITS = Object.freeze({
|
|
|
31
31
|
});
|
|
32
32
|
|
|
33
33
|
const execFileAsync = promisify(execFile);
|
|
34
|
-
const
|
|
35
|
-
/^Spotify Extended Streaming History\/
|
|
34
|
+
const historyMemberPattern =
|
|
35
|
+
/^Spotify Extended Streaming History\/Streaming_History_(?:Audio|Video)_([0-9]{4})(?:_([0-9]+))?\.json$/u;
|
|
36
36
|
const trackUriPattern = /^spotify:track:([A-Za-z0-9]{22})$/u;
|
|
37
37
|
const sha256Pattern = /^[a-f0-9]{64}$/u;
|
|
38
38
|
const unsafeTextPattern =
|
|
@@ -222,7 +222,7 @@ function normalizedMemberNames(memberNames) {
|
|
|
222
222
|
const normalized = memberNames.map((value) => {
|
|
223
223
|
if (
|
|
224
224
|
typeof value !== "string" ||
|
|
225
|
-
!/^
|
|
225
|
+
!/^Streaming_History_(?:Audio|Video)_[0-9]{4}(?:_[0-9]+)?\.json$/u.test(value)
|
|
226
226
|
) {
|
|
227
227
|
fail("Spotify Extended History member names are invalid.");
|
|
228
228
|
}
|
|
@@ -418,12 +418,12 @@ async function unzip(args, maximumBytes) {
|
|
|
418
418
|
}
|
|
419
419
|
}
|
|
420
420
|
|
|
421
|
-
function
|
|
421
|
+
function selectedHistoryMembers(listing) {
|
|
422
422
|
const selected = listing
|
|
423
423
|
.split(/\r?\n/u)
|
|
424
424
|
.filter(Boolean)
|
|
425
425
|
.map((name) => {
|
|
426
|
-
const match =
|
|
426
|
+
const match = historyMemberPattern.exec(name);
|
|
427
427
|
return match
|
|
428
428
|
? {
|
|
429
429
|
name,
|
|
@@ -439,13 +439,13 @@ function selectedAudioMembers(listing) {
|
|
|
439
439
|
: left.year - right.year,
|
|
440
440
|
);
|
|
441
441
|
if (selected.length < 1) {
|
|
442
|
-
fail("The ZIP does not contain Spotify Extended History audio files.");
|
|
442
|
+
fail("The ZIP does not contain Spotify Extended History audio/video files.");
|
|
443
443
|
}
|
|
444
444
|
if (selected.length > SPOTIFY_EXTENDED_HISTORY_LIMITS.maximumHistoryMembers) {
|
|
445
|
-
fail("The ZIP contains too many Spotify Extended History audio files.");
|
|
445
|
+
fail("The ZIP contains too many Spotify Extended History audio/video files.");
|
|
446
446
|
}
|
|
447
447
|
if (new Set(selected.map(({ name }) => name)).size !== selected.length) {
|
|
448
|
-
fail("The ZIP contains duplicate Spotify Extended History audio files.");
|
|
448
|
+
fail("The ZIP contains duplicate Spotify Extended History audio/video files.");
|
|
449
449
|
}
|
|
450
450
|
return selected;
|
|
451
451
|
}
|
|
@@ -481,7 +481,7 @@ export async function readSpotifyExtendedStreamingHistoryArchive({
|
|
|
481
481
|
}
|
|
482
482
|
|
|
483
483
|
const listing = await unzip(["-Z1", resolvedPath], 1024 * 1024);
|
|
484
|
-
const members =
|
|
484
|
+
const members = selectedHistoryMembers(listing);
|
|
485
485
|
const records = [];
|
|
486
486
|
let totalJsonBytes = 0;
|
|
487
487
|
for (const member of members) {
|
|
@@ -500,12 +500,12 @@ export async function readSpotifyExtendedStreamingHistoryArchive({
|
|
|
500
500
|
parsed = JSON.parse(jsonText.replace(/^\uFEFF/u, ""));
|
|
501
501
|
} catch {
|
|
502
502
|
fail(
|
|
503
|
-
`Spotify Extended History audio file for ${member.year} is invalid JSON.`,
|
|
503
|
+
`Spotify Extended History audio/video file for ${member.year} is invalid JSON.`,
|
|
504
504
|
);
|
|
505
505
|
}
|
|
506
506
|
if (!Array.isArray(parsed)) {
|
|
507
507
|
fail(
|
|
508
|
-
`Spotify Extended History audio file for ${member.year} is not an array.`,
|
|
508
|
+
`Spotify Extended History audio/video file for ${member.year} is not an array.`,
|
|
509
509
|
);
|
|
510
510
|
}
|
|
511
511
|
records.push(...parsed);
|
|
@@ -45,7 +45,7 @@ export async function readSpotifyHistoryArchive(options = {}) {
|
|
|
45
45
|
const hasExtendedHistory = listing
|
|
46
46
|
.split(/\r?\n/u)
|
|
47
47
|
.some((name) =>
|
|
48
|
-
/^Spotify Extended Streaming History\/
|
|
48
|
+
/^Spotify Extended Streaming History\/Streaming_History_(?:Audio|Video)_[0-9]{4}(?:_[0-9]+)?\.json$/u.test(
|
|
49
49
|
name,
|
|
50
50
|
),
|
|
51
51
|
);
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { isUuid, uuidV5 } from "../../core/uuid-v5.mjs";
|
|
4
|
+
import { spotifyEntityRefId, spotifyProfileEvidenceKey, spotifyResolvedTrackRef } from "./account-data-profile.mjs";
|
|
5
|
+
|
|
6
|
+
export const SPOTIFY_LIBRARY_SOURCE = "spotify.web_api_library";
|
|
7
|
+
export const SPOTIFY_LIBRARY_FORMAT = "spotify_web_api_library_v1";
|
|
8
|
+
export const SPOTIFY_LIBRARY_SYSTEM = "spotify_web_api";
|
|
9
|
+
// Read-only access for a library import; playback control is never requested.
|
|
10
|
+
export const SPOTIFY_LIBRARY_SCOPES = Object.freeze([
|
|
11
|
+
"user-read-recently-played", "user-library-read", "user-follow-read", "user-top-read", "playlist-read-private",
|
|
12
|
+
]);
|
|
13
|
+
export const SPOTIFY_LIBRARY_LIMITS = Object.freeze({
|
|
14
|
+
savedTracks: 10_000, savedAlbums: 2_000, followedArtists: 2_000,
|
|
15
|
+
playlists: 200, itemsPerPlaylist: 2_000, playlistItems: 20_000, topItems: 50,
|
|
16
|
+
});
|
|
17
|
+
const TOP_PERIODS = Object.freeze({
|
|
18
|
+
short_term: "the last four weeks", medium_term: "the last six months", long_term: "about the last year",
|
|
19
|
+
});
|
|
20
|
+
const IMPORT_NAMESPACE = "77b3424f-3dd4-4fd9-ac70-42a6b9397dae";
|
|
21
|
+
const EVIDENCE_NAMESPACE = "2efe152f-5455-4de6-b88f-09597719abb5";
|
|
22
|
+
const BATCH_NAMESPACE = "d2b37594-838e-452b-8810-f6052f510e65";
|
|
23
|
+
const MEMBERS = ["saved_tracks", "saved_albums", "followed_artists", "playlists", "top_items"];
|
|
24
|
+
|
|
25
|
+
function idFromUri(uri, type) {
|
|
26
|
+
const match = typeof uri === "string" ? new RegExp(`^spotify:${type}:([A-Za-z0-9]{22})$`, "u").exec(uri) : null;
|
|
27
|
+
return match?.[1] ?? null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function track(item, extra = {}) {
|
|
31
|
+
const id = item?.id ?? idFromUri(item?.uri, "track");
|
|
32
|
+
if (!id || !item.name || !Array.isArray(item.artists) || !item.artists.length) return null;
|
|
33
|
+
return { id, name: item.name, artists: item.artists, ...(item.album ? { album: item.album } : {}), ...extra };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function pages(read, cap, onPage) {
|
|
37
|
+
const items = [];
|
|
38
|
+
let cursor = { offset: 0 };
|
|
39
|
+
for (;;) {
|
|
40
|
+
const page = await read(cursor);
|
|
41
|
+
for (const item of page.items ?? []) {
|
|
42
|
+
if (items.length >= cap) return { items, truncated: true };
|
|
43
|
+
items.push(item);
|
|
44
|
+
}
|
|
45
|
+
onPage?.(items.length);
|
|
46
|
+
if (!page.has_more) return { items, truncated: false };
|
|
47
|
+
if (page.next_after) { cursor = { after: page.next_after }; continue; }
|
|
48
|
+
// Playlist pages report their offset and size rather than a next offset.
|
|
49
|
+
const next = Number.isSafeInteger(page.next_offset) ? page.next_offset
|
|
50
|
+
: Number.isSafeInteger(page.offset) && Number.isSafeInteger(page.limit) ? page.offset + page.limit : null;
|
|
51
|
+
if (next === null || next <= (cursor.offset ?? -1)) return { items, truncated: true };
|
|
52
|
+
cursor = { offset: next };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const clip = value => Array.from(value).slice(0, 512).join("");
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Read the listener's Spotify library through the authorized client: saved
|
|
60
|
+
* tracks and albums, followed artists, their own playlists, and Spotify's top
|
|
61
|
+
* artists and tracks. Every read is bounded; a playlist that cannot be read is
|
|
62
|
+
* skipped and counted rather than failing the whole snapshot.
|
|
63
|
+
*/
|
|
64
|
+
export async function readSpotifyLibrarySnapshot(client, { signal, onProgress = () => {}, limits = SPOTIFY_LIBRARY_LIMITS } = {}) {
|
|
65
|
+
const progress = (stage, count) => { signal?.throwIfAborted(); onProgress({ stage, count }); };
|
|
66
|
+
const account = await client.getAccount({ signal });
|
|
67
|
+
const saved = await pages(({ offset }) => client.getSavedItems({ type: "tracks", limit: 50, offset }, { signal }),
|
|
68
|
+
limits.savedTracks, count => progress("saved_tracks", count));
|
|
69
|
+
const albums = await pages(({ offset }) => client.getSavedItems({ type: "albums", limit: 50, offset }, { signal }),
|
|
70
|
+
limits.savedAlbums, count => progress("saved_albums", count));
|
|
71
|
+
const artists = await pages(({ after }) => client.getFollowedArtists({ limit: 50, ...(after ? { after } : {}) }, { signal }),
|
|
72
|
+
limits.followedArtists, count => progress("followed_artists", count));
|
|
73
|
+
const listed = await pages(({ offset }) => client.getCurrentUserPlaylists({ limit: 50, offset }, { signal }),
|
|
74
|
+
limits.playlists, count => progress("playlists", count));
|
|
75
|
+
const playlists = [];
|
|
76
|
+
let playlistItems = 0;
|
|
77
|
+
let skippedPlaylists = 0;
|
|
78
|
+
let truncatedPlaylistItems = false;
|
|
79
|
+
// Followed playlists are someone else's curation; only the listener's own count.
|
|
80
|
+
for (const playlist of listed.items.filter(item => account.account_id && item.owner_id === account.account_id)) {
|
|
81
|
+
if (playlistItems >= limits.playlistItems) { truncatedPlaylistItems = true; break; }
|
|
82
|
+
try {
|
|
83
|
+
const read = await pages(({ offset }) => client.getPlaylistItems({ playlistId: playlist.id, limit: 50, offset }, { signal }),
|
|
84
|
+
Math.min(limits.itemsPerPlaylist, limits.playlistItems - playlistItems));
|
|
85
|
+
truncatedPlaylistItems ||= read.truncated;
|
|
86
|
+
const tracks = read.items.flatMap((entry, index) => {
|
|
87
|
+
if (entry.is_local || entry.item?.type !== "track") return [];
|
|
88
|
+
const value = track(entry.item, { position: index + 1, ...(entry.added_at ? { added_at: entry.added_at } : {}) });
|
|
89
|
+
return value ? [value] : [];
|
|
90
|
+
});
|
|
91
|
+
playlistItems += read.items.length;
|
|
92
|
+
playlists.push({ id: playlist.id, name: playlist.name, tracks });
|
|
93
|
+
progress("playlist_items", playlistItems);
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (error?.name === "AbortError" || signal?.aborted) throw error;
|
|
96
|
+
skippedPlaylists++;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const top = { artists: {}, tracks: {} };
|
|
100
|
+
for (const type of ["artists", "tracks"]) {
|
|
101
|
+
for (const timeRange of Object.keys(TOP_PERIODS)) {
|
|
102
|
+
const result = await client.getTopItems({ type, timeRange, limit: limits.topItems }, { signal });
|
|
103
|
+
top[type][timeRange] = (result.items ?? []).flatMap((item, index) => {
|
|
104
|
+
const rank = item.affinity_rank ?? index + 1;
|
|
105
|
+
if (type === "tracks") {
|
|
106
|
+
const value = track(item, { rank });
|
|
107
|
+
return value ? [value] : [];
|
|
108
|
+
}
|
|
109
|
+
return item.name ? [{ name: item.name, rank, ...(idFromUri(item.uri, "artist") ? { id: idFromUri(item.uri, "artist") } : {}) }] : [];
|
|
110
|
+
});
|
|
111
|
+
progress("top_items", Object.values(top).reduce((sum, ranges) => sum + Object.values(ranges).flat().length, 0));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
saved_tracks: saved.items.flatMap(item => track(item, item.added_at ? { added_at: item.added_at } : {}) ?? []),
|
|
116
|
+
saved_albums: albums.items.flatMap(item => {
|
|
117
|
+
const id = idFromUri(item.uri, "album");
|
|
118
|
+
return id && item.name ? [{ id, name: item.name, artists: item.artists ?? [], ...(item.added_at ? { added_at: item.added_at } : {}) }] : [];
|
|
119
|
+
}),
|
|
120
|
+
followed_artists: artists.items.flatMap(item => {
|
|
121
|
+
const id = idFromUri(item.uri, "artist");
|
|
122
|
+
return id && item.name ? [{ id, name: item.name }] : [];
|
|
123
|
+
}),
|
|
124
|
+
playlists,
|
|
125
|
+
top_artists: top.artists,
|
|
126
|
+
top_tracks: top.tracks,
|
|
127
|
+
truncated: { saved_tracks: saved.truncated, saved_albums: albums.truncated, followed_artists: artists.truncated,
|
|
128
|
+
playlists: listed.truncated, playlist_items: truncatedPlaylistItems },
|
|
129
|
+
skipped_playlists: skippedPlaylists,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function digestOf(value) {
|
|
134
|
+
return createHash("sha256").update(value).digest("hex");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function projectSpotifyLibrarySnapshot({ subjectId, snapshot, capturedAt = new Date().toISOString() } = {}) {
|
|
138
|
+
if (!isUuid(subjectId)) throw new TypeError("A trusted subject ID is required.");
|
|
139
|
+
subjectId = subjectId.toLowerCase();
|
|
140
|
+
capturedAt = new Date(capturedAt).toISOString();
|
|
141
|
+
// An identical library is the same import, so a repeated sync changes nothing.
|
|
142
|
+
const content = JSON.stringify({ format: SPOTIFY_LIBRARY_FORMAT, ...snapshot });
|
|
143
|
+
const digest = digestOf(content);
|
|
144
|
+
const size = Buffer.byteLength(content);
|
|
145
|
+
const profileImportId = uuidV5(`${subjectId}\0${digest}\0${SPOTIFY_LIBRARY_FORMAT}`, IMPORT_NAMESPACE);
|
|
146
|
+
const tracks = new Map();
|
|
147
|
+
const evidence = [];
|
|
148
|
+
const notAfterCapture = value => (value && value <= capturedAt ? value : capturedAt);
|
|
149
|
+
const addTrack = item => {
|
|
150
|
+
const artist = clip(item.artists.join(", "));
|
|
151
|
+
const ref = spotifyResolvedTrackRef({ id: item.id, title: item.name, artist, album: item.album, observedAt: capturedAt });
|
|
152
|
+
if (!tracks.has(ref.track_ref_id)) tracks.set(ref.track_ref_id, ref);
|
|
153
|
+
return { entity_type: "track", entity_ref_id: ref.track_ref_id, track_ref_id: ref.track_ref_id, track_ref_revision: 1,
|
|
154
|
+
label: item.name, artist_credit: artist, ...(item.album ? { release: item.album } : {}) };
|
|
155
|
+
};
|
|
156
|
+
const add = ({ kind, logicalKey, strength, entity, member, observedAt = capturedAt, attributes = {} }) => {
|
|
157
|
+
const evidenceKey = spotifyProfileEvidenceKey(subjectId, kind, logicalKey);
|
|
158
|
+
evidence.push({
|
|
159
|
+
schema_version: 1, profile_evidence_id: uuidV5(`${digest}\0${evidenceKey}`, EVIDENCE_NAMESPACE),
|
|
160
|
+
evidence_key: evidenceKey, subject_id: subjectId, evidence_kind: kind, direction: "supports",
|
|
161
|
+
strength_class: strength, entity, observed_at: notAfterCapture(observedAt), attributes,
|
|
162
|
+
provenance: { source_kind: "import", source_system: SPOTIFY_LIBRARY_SYSTEM, source_member: member,
|
|
163
|
+
captured_at: capturedAt, profile_import_id: profileImportId },
|
|
164
|
+
});
|
|
165
|
+
};
|
|
166
|
+
for (const [index, item] of snapshot.saved_tracks.entries()) {
|
|
167
|
+
add({ kind: "library_track_saved", logicalKey: `saved-track\0${item.id}`, strength: "explicit", entity: addTrack(item),
|
|
168
|
+
member: "saved_tracks", observedAt: item.added_at, attributes: { source_position: index + 1 } });
|
|
169
|
+
}
|
|
170
|
+
for (const [index, item] of snapshot.saved_albums.entries()) {
|
|
171
|
+
add({ kind: "library_album_saved", logicalKey: `saved-album\0${item.id}`, strength: "explicit", member: "saved_albums",
|
|
172
|
+
entity: { entity_type: "album", entity_ref_id: spotifyEntityRefId("album", item.id), label: item.name,
|
|
173
|
+
...(item.artists.length ? { artist_credit: clip(item.artists.join(", ")) } : {}) },
|
|
174
|
+
observedAt: item.added_at, attributes: { source_position: index + 1 } });
|
|
175
|
+
}
|
|
176
|
+
for (const [index, item] of snapshot.followed_artists.entries()) {
|
|
177
|
+
add({ kind: "library_artist_followed", logicalKey: `followed-artist\0${item.id}`, strength: "explicit", member: "followed_artists",
|
|
178
|
+
entity: { entity_type: "artist", entity_ref_id: spotifyEntityRefId("artist", item.id), label: item.name },
|
|
179
|
+
attributes: { source_position: index + 1 } });
|
|
180
|
+
}
|
|
181
|
+
for (const playlist of snapshot.playlists) {
|
|
182
|
+
const playlistRef = spotifyEntityRefId("playlist", playlist.id);
|
|
183
|
+
for (const item of playlist.tracks) {
|
|
184
|
+
add({ kind: "playlist_track_added", logicalKey: `playlist-track\0${playlistRef}\0${item.id}\0${item.position}`,
|
|
185
|
+
strength: "curated", entity: addTrack(item), member: "playlists", observedAt: item.added_at,
|
|
186
|
+
attributes: { playlist_ref_id: playlistRef, playlist_name: playlist.name, playlist_position: item.position } });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
for (const [timeRange, period] of Object.entries(TOP_PERIODS)) {
|
|
190
|
+
for (const item of snapshot.top_artists[timeRange] ?? []) {
|
|
191
|
+
add({ kind: "top_artist_ranked", logicalKey: `top-artist\0${timeRange}\0${item.id ?? item.name}`, strength: "provider_derived",
|
|
192
|
+
member: "top_items", entity: { entity_type: "artist",
|
|
193
|
+
entity_ref_id: item.id ? spotifyEntityRefId("artist", item.id) : uuidV5(`artist\0${item.name}`, EVIDENCE_NAMESPACE), label: item.name },
|
|
194
|
+
attributes: { rank: item.rank, period, time_range: timeRange } });
|
|
195
|
+
}
|
|
196
|
+
for (const item of snapshot.top_tracks[timeRange] ?? []) {
|
|
197
|
+
add({ kind: "top_track_ranked", logicalKey: `top-track\0${timeRange}\0${item.id}`, strength: "provider_derived",
|
|
198
|
+
member: "top_items", entity: addTrack(item), attributes: { rank: item.rank, period, time_range: timeRange } });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (!evidence.length) throw new Error("Your Spotify library looks empty, so there is nothing to import yet.");
|
|
202
|
+
const manifest = { subject_id: subjectId, source_format: SPOTIFY_LIBRARY_FORMAT, archive_sha256: digest,
|
|
203
|
+
archive_size_bytes: size, member_names: MEMBERS, imported_at: capturedAt };
|
|
204
|
+
const unique = values => new Set(values).size;
|
|
205
|
+
return {
|
|
206
|
+
bundle: {
|
|
207
|
+
source_key: SPOTIFY_LIBRARY_SOURCE, captured_at: capturedAt, cursor_after_ms: null,
|
|
208
|
+
track_refs: [...tracks.values()], listening_events: [],
|
|
209
|
+
import_batch: { ...manifest, import_batch_id: uuidV5(`${subjectId}\0${digest}`, BATCH_NAMESPACE),
|
|
210
|
+
data_scope: "account_library_snapshot", input_records: 0, earliest_occurred_at: null, latest_occurred_at: null },
|
|
211
|
+
profile_import: { ...manifest, profile_import_id: profileImportId, source_key: SPOTIFY_LIBRARY_SOURCE, input_records: evidence.length },
|
|
212
|
+
profile_evidence: evidence,
|
|
213
|
+
},
|
|
214
|
+
preview: {
|
|
215
|
+
kind: "spotify-library", sourceLabel: "Your Spotify library", capturedAt,
|
|
216
|
+
savedTracks: snapshot.saved_tracks.length, savedAlbums: snapshot.saved_albums.length,
|
|
217
|
+
followedArtists: snapshot.followed_artists.length, playlists: snapshot.playlists.length,
|
|
218
|
+
playlistTracks: snapshot.playlists.reduce((sum, playlist) => sum + playlist.tracks.length, 0),
|
|
219
|
+
topArtists: unique(Object.values(snapshot.top_artists).flat().map(item => item.id ?? item.name)),
|
|
220
|
+
topTracks: unique(Object.values(snapshot.top_tracks).flat().map(item => item.id)),
|
|
221
|
+
tracks: tracks.size, profileEvidence: evidence.length,
|
|
222
|
+
truncated: Object.entries(snapshot.truncated ?? {}).filter(([, value]) => value).map(([key]) => key),
|
|
223
|
+
skippedPlaylists: snapshot.skipped_playlists ?? 0,
|
|
224
|
+
scopeNote: "What you keep and follow, your own playlists, and the artists and tracks Spotify ranks highest for you. It shows what you keep, not every play.",
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
}
|
|
@@ -192,6 +192,8 @@ export function projectSpotifyRecentActivity({
|
|
|
192
192
|
source_key: SPOTIFY_RECENT_ACTIVITY_SOURCE,
|
|
193
193
|
captured_at: normalizedCapturedAt,
|
|
194
194
|
cursor_after_ms: cursorAfterMs > 0 ? cursorAfterMs : null,
|
|
195
|
+
// Spotify keeps only the last 50 plays: a full read may have lost older ones.
|
|
196
|
+
window_full: page.items.length >= 50 || page.has_more === true,
|
|
195
197
|
track_refs: [...trackRefs.values()],
|
|
196
198
|
listening_events: [...listeningEvents.values()],
|
|
197
199
|
};
|