@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.
Files changed (121) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +153 -68
  3. package/contracts/v1/definitions.schema.json +1 -169
  4. package/package.json +7 -23
  5. package/scripts/contract-lib.mjs +0 -7
  6. package/scripts/contract-semantics.mjs +0 -1904
  7. package/scripts/moondog.mjs +285 -74
  8. package/src/core/apple-library-source-status.mjs +0 -16
  9. package/src/core/apple-projection-domain-services.mjs +168 -36
  10. package/src/core/application-update.mjs +320 -0
  11. package/src/core/capability-catalog.mjs +278 -55
  12. package/src/core/listening-now.mjs +108 -0
  13. package/src/core/listening-profile-domain-services.mjs +153 -41
  14. package/src/core/listening-upkeep.mjs +31 -0
  15. package/src/core/moondog-application.mjs +1908 -104
  16. package/src/core/playback-preferences.mjs +97 -0
  17. package/src/core/spotify-listening-intent.mjs +121 -0
  18. package/src/core/synthetic-domain-services.mjs +24 -3
  19. package/src/demo/moondog-demo.mjs +4 -22
  20. package/src/i18n/index.mjs +285 -0
  21. package/src/i18n/messages/de.mjs +128 -0
  22. package/src/i18n/messages/en.mjs +129 -0
  23. package/src/i18n/messages/es.mjs +128 -0
  24. package/src/i18n/messages/fr.mjs +128 -0
  25. package/src/i18n/messages/it.mjs +128 -0
  26. package/src/i18n/messages/ja.mjs +128 -0
  27. package/src/i18n/messages/ko.mjs +128 -0
  28. package/src/i18n/messages/pt.mjs +128 -0
  29. package/src/i18n/messages/zh-Hant.mjs +128 -0
  30. package/src/i18n/messages/zh.mjs +128 -0
  31. package/src/i18n/preferences.mjs +34 -0
  32. package/src/i18n/screen/es.mjs +966 -0
  33. package/src/i18n/screen/ja.mjs +966 -0
  34. package/src/i18n/screen/pt.mjs +966 -0
  35. package/src/i18n/screen/zh.mjs +966 -0
  36. package/src/importers/apple-music-library/index.mjs +0 -16
  37. package/src/importers/apple-music-library/sqlite-projection.mjs +51 -9
  38. package/src/integrations/apple-music/catalog.mjs +123 -14
  39. package/src/integrations/apple-music/music-app.mjs +232 -0
  40. package/src/integrations/apple-music/storefront.mjs +28 -0
  41. package/src/integrations/apple-music/terminal-preview-player.mjs +221 -0
  42. package/src/integrations/lastfm/history.mjs +221 -0
  43. package/src/integrations/lastfm/settings.mjs +75 -0
  44. package/src/integrations/netease/ncm-cli-player.mjs +321 -0
  45. package/src/integrations/open-music-similarity/artist-radio.mjs +26 -18
  46. package/src/integrations/public-playlists.mjs +2 -1
  47. package/src/integrations/spotify/account-data-profile.mjs +14 -6
  48. package/src/integrations/spotify/authentication.mjs +149 -15
  49. package/src/integrations/spotify/catalog-resolver.mjs +8 -4
  50. package/src/integrations/spotify/connection.mjs +2 -1
  51. package/src/integrations/spotify/credential-store.mjs +4 -0
  52. package/src/integrations/spotify/extended-streaming-history.mjs +11 -11
  53. package/src/integrations/spotify/history-archive.mjs +1 -1
  54. package/src/integrations/spotify/library-snapshot.mjs +227 -0
  55. package/src/integrations/spotify/recent-activity.mjs +2 -0
  56. package/src/integrations/spotify/service.mjs +873 -69
  57. package/src/integrations/spotify/web-api-client.mjs +407 -127
  58. package/src/integrations/web/brave-web.mjs +45 -0
  59. package/src/integrations/web/codex-web.mjs +15 -82
  60. package/src/integrations/web/deepseek-web.mjs +81 -0
  61. package/src/integrations/web/web-research.mjs +119 -0
  62. package/src/integrations/web/web-result.mjs +215 -0
  63. package/src/integrations/youtube-music/takeout.mjs +3 -1
  64. package/src/memory/local-memory-store.mjs +469 -41
  65. package/src/profile/history-import.mjs +42 -6
  66. package/src/profile/listener-profile-build.mjs +378 -0
  67. package/src/profile/listener-profile-store.mjs +130 -0
  68. package/src/profile/listener-profile-verification.mjs +68 -0
  69. package/src/profile/listener-profile-worker.mjs +172 -0
  70. package/src/profile/listening-history-store.mjs +424 -8
  71. package/src/profile/listening-profile-projection.mjs +242 -76
  72. package/src/profile/local-music-data.mjs +1 -1
  73. package/src/profile/music-providers.mjs +11 -7
  74. package/src/profile/profile-exploration.mjs +84 -0
  75. package/src/profile/recorded-measures.mjs +24 -0
  76. package/src/profile/spotify-archive-taste.mjs +0 -4
  77. package/src/runtime/pi/agent-runtime.mjs +2204 -605
  78. package/src/runtime/pi/authentication.mjs +34 -1
  79. package/src/runtime/pi/configured-runtime.mjs +22 -6
  80. package/src/runtime/pi/conversation-name.mjs +53 -0
  81. package/src/runtime/pi/memory-agent-runtime.mjs +2 -2
  82. package/src/runtime/pi/model-catalog.mjs +28 -5
  83. package/src/runtime/pi/model-settings.mjs +202 -0
  84. package/src/runtime/pi/model-transport.mjs +10 -0
  85. package/src/runtime/pi/persistent-credential-store.mjs +67 -31
  86. package/src/runtime/pi/profile-build-runtime.mjs +160 -0
  87. package/src/runtime/pi/public-model-catalog.mjs +288 -0
  88. package/src/runtime/pi/runtime-settings.mjs +10 -2
  89. package/src/runtime/pi/skills/listener-profile/SKILL.md +46 -0
  90. package/src/runtime/pi/tool-trace.mjs +37 -0
  91. package/src/surfaces/cli/auth-command.mjs +12 -4
  92. package/src/surfaces/cli/brand-components.mjs +310 -132
  93. package/src/surfaces/cli/brand-theme.mjs +51 -28
  94. package/src/surfaces/cli/command-completions.mjs +58 -42
  95. package/src/surfaces/cli/format-output.mjs +199 -388
  96. package/src/surfaces/cli/history-import-view.mjs +292 -139
  97. package/src/surfaces/cli/import-guides.mjs +2 -0
  98. package/src/surfaces/cli/listener-profile-command.mjs +92 -0
  99. package/src/surfaces/cli/listening-menu.mjs +7 -4
  100. package/src/surfaces/cli/profile-command.mjs +30 -21
  101. package/src/surfaces/cli/seasons-chart.mjs +87 -0
  102. package/src/surfaces/cli/spotify-command.mjs +54 -6
  103. package/src/surfaces/cli/taste-profile-model.mjs +78 -79
  104. package/src/surfaces/cli/taste-profile-view.mjs +33 -26
  105. package/src/surfaces/cli/terminal-art.mjs +140 -35
  106. package/src/surfaces/cli/tui.mjs +1066 -357
  107. package/src/surfaces/cli/update-command.mjs +32 -0
  108. package/src/surfaces/cli/web-command.mjs +53 -14
  109. package/src/surfaces/html/tasteprint.mjs +43 -70
  110. package/contracts/v1/capability-policy.schema.json +0 -572
  111. package/contracts/v1/profile-evidence.schema.json +0 -237
  112. package/contracts/v1/tool-invocation.schema.json +0 -318
  113. package/contracts/v1/tool-receipt.schema.json +0 -1066
  114. package/scripts/demo-studio.mjs +0 -58
  115. package/src/core/apple-data-migration.mjs +0 -50
  116. package/src/demo/moondog-interactive-tasteprint-demo.mjs +0 -293
  117. package/src/demo/moondog-tasteprint-demo.mjs +0 -631
  118. package/src/integrations/spotify/index.mjs +0 -80
  119. package/src/surfaces/web/showcase.mjs +0 -272
  120. package/src/surfaces/web/studio-page.mjs +0 -4148
  121. 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 writeCredential(credential, { signal });
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
- const refreshed = await refreshSpotifyAccessToken({
766
- clientId,
767
- refreshToken: credential.refreshToken,
768
- refreshExpiresAt: credential.refreshExpiresAt,
769
- scopes: credential.scope,
770
- fetchImpl,
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 audioMemberPattern =
35
- /^Spotify Extended Streaming History\/Streaming_History_Audio_([0-9]{4})(?:_([0-9]+))?\.json$/u;
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
- !/^Streaming_History_Audio_[0-9]{4}(?:_[0-9]+)?\.json$/u.test(value)
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 selectedAudioMembers(listing) {
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 = audioMemberPattern.exec(name);
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 = selectedAudioMembers(listing);
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\/Streaming_History_Audio_[0-9]{4}(?:_[0-9]+)?\.json$/u.test(
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
  };