diff --git a/assets/spotify/icon.png b/assets/spotify/icon.png
index a00d8f7d..dbb66d13 100644
Binary files a/assets/spotify/icon.png and b/assets/spotify/icon.png differ
diff --git a/package.json b/package.json
index d58e2fa2..629141c3 100644
--- a/package.json
+++ b/package.json
@@ -86,8 +86,10 @@
"passport": "^0.5.2",
"passport-facebook": "^3.0.0",
"passport-google-oauth20": "^2.0.0",
+ "passport-spotify": "^2.0.0",
"pdf-parse": "^1.1.1",
"sanitize-html": "^2.13.1",
+ "spotify-api-sdk": "^1.0.0",
"string-strip-html": "8.5.0",
"tdlib-native": "^3.1.0",
"ts-mocha": "^9.0.2",
diff --git a/src/providers/google/interfaces.ts b/src/providers/google/interfaces.ts
index faa0795c..632aa33c 100644
--- a/src/providers/google/interfaces.ts
+++ b/src/providers/google/interfaces.ts
@@ -43,7 +43,7 @@ export enum YoutubeActivityType {
}
export interface Person {
- email: string
+ email?: string
displayName?: string
}
diff --git a/src/providers/spotify/README.md b/src/providers/spotify/README.md
new file mode 100644
index 00000000..1a143573
--- /dev/null
+++ b/src/providers/spotify/README.md
@@ -0,0 +1,28 @@
+# Spotify Provider
+
+## Configuration
+
+1. Go to the [Spotify Developer Dashboard](https://developer.spotify.com/dashboard)
+2. Log in with your Spotify account
+3. Click `Create an App`
+4. Fill in the app name and description, and choose `Web API`
+5. Once created, click `Settings`
+6. Note down your `Client ID` and `Client Secret`
+7. Add your redirect URI (typically `http://localhost:5021/callback/spotify` for local development)
+
+## Spotify Favourite(User's Top Tracks)
+
+GET https://api.spotify.com/v1/me/top/tracks
+
+
+### Query Parameters
+
+| Parameter | Value | Description |
+|------------|----------|--------------------------------------------|
+| limit | integer | Optional. The maximum number of items to return. Default: 20. Minimum: 1. Maximum: 50. |
+| offset | integer | Optional. The index of the first item to return. Default: 0 |
+| time_range | string | Optional. Over what time frame the affinities are computed. Valid values:
• `short_term` (last 4 weeks)
• `medium_term` (last 6 months)
• `long_term` (calculated from several years of data and including all new data as it becomes available). Default: medium_term |
+
+
+
+
diff --git a/src/providers/spotify/index.ts b/src/providers/spotify/index.ts
new file mode 100644
index 00000000..2c31de30
--- /dev/null
+++ b/src/providers/spotify/index.ts
@@ -0,0 +1,171 @@
+import { Request, Response } from "express";
+import Base from "../BaseProvider";
+import { SpotifyProviderConfig } from "./interfaces";
+import { ConnectionCallbackResponse, PassportProfile } from "../../interfaces";
+import { Client, OAuthScopeEnum, OAuthToken } from "spotify-api-sdk";
+import SpotifyFollowing from "./spotify-following";
+import SpotifyFavouriteHandler from "./spotify-favourite";
+import SpotifyPlayHistory from "./spotify-history";
+import SpotifyPlaylistHandler from "./spotify-playlist";
+
+const { Strategy: SpotifyStrategy } = require("passport-spotify");
+const passport = require("passport");
+
+export default class SpotifyProvider extends Base {
+ protected config: SpotifyProviderConfig;
+
+ public getProviderName() {
+ return "spotify";
+ }
+
+ public getProviderLabel() {
+ return "Spotify";
+ }
+
+ public getProviderApplicationUrl() {
+ return "https://www.spotify.com/";
+ }
+
+ public syncHandlers(): any[] {
+ return [
+ SpotifyFollowing,
+ SpotifyFavouriteHandler,
+ SpotifyPlayHistory,
+ SpotifyPlaylistHandler
+ ];
+ }
+
+ public getScopes(): OAuthScopeEnum[] {
+ return [
+ OAuthScopeEnum.PlaylistReadPrivate,
+ OAuthScopeEnum.UserReadPrivate,
+ OAuthScopeEnum.UserReadEmail,
+ OAuthScopeEnum.UserFollowRead,
+ OAuthScopeEnum.UserTopRead,
+ OAuthScopeEnum.UserReadRecentlyPlayed
+ ];
+ }
+
+ public async connect(req: Request, res: Response, next: any): Promise {
+ this.init();
+
+ const auth = await passport.authenticate("spotify", {
+ scope: this.getScopes(),
+ showDialog: true,
+ });
+
+ return auth(req, res, next);
+ }
+
+ public async callback(req: Request, res: Response, next: any): Promise {
+ this.init();
+
+ const promise = new Promise((resolve, reject) => {
+ const auth = passport.authenticate(
+ "spotify",
+ {
+ failureRedirect: "/failure/spotify",
+ failureMessage: true,
+ },
+ function (err: any, data: any) {
+ if (err) {
+ reject(err);
+ } else {
+ // Format profile into PassportProfile structure
+ const profile = this.formatProfile(data.profile);
+
+ const connectionToken: ConnectionCallbackResponse = {
+ id: profile.id,
+ accessToken: data.accessToken,
+ refreshToken: data.refreshToken,
+ profile: {
+ username: profile.connectionProfile.username,
+ ...profile,
+ },
+ };
+
+ resolve(connectionToken);
+ }
+ }.bind(this) // Bind this to access the formatProfile method
+ );
+
+ auth(req, res, next);
+ });
+
+ const result = await promise;
+ return result;
+ }
+
+ public async getApi(accessToken?: string, refreshToken?: string): Promise {
+
+ if (!accessToken) {
+ throw new Error("Access token is required");
+ }
+
+ const token: OAuthToken = {
+ accessToken: accessToken,
+ refreshToken: refreshToken,
+ tokenType: "Bearer"
+ }
+
+ const client = new Client({
+ authorizationCodeAuthCredentials: {
+ oAuthClientId: this.config.clientId,
+ oAuthClientSecret: this.config.clientSecret,
+ oAuthRedirectUri: this.config.callbackUrl,
+ oAuthScopes: this.getScopes(),
+ oAuthToken: token
+ },
+ });
+
+ return client;
+ }
+
+ public init() {
+ passport.use(
+ new SpotifyStrategy(
+ {
+ clientID: this.config.clientId,
+ clientSecret: this.config.clientSecret,
+ callbackURL: this.config.callbackUrl,
+ },
+ function (
+ accessToken: string,
+ refreshToken: string,
+ profile: any,
+ cb: any
+ ) {
+ return cb(null, {
+ accessToken,
+ refreshToken,
+ profile,
+ });
+ }
+ )
+ );
+ }
+
+ private formatProfile(spotifyProfile: any): PassportProfile {
+ const displayName = spotifyProfile.displayName || spotifyProfile.username;
+ const email = spotifyProfile.emails && spotifyProfile.emails.length ? spotifyProfile.emails[0].value : null;
+
+ const profile: PassportProfile = {
+ id: spotifyProfile.id,
+ provider: this.getProviderName(),
+ displayName: displayName,
+ name: {
+ familyName: (displayName && displayName.split(" ").slice(-1)[0]) || "",
+ givenName: (displayName && displayName.split(" ").slice(0, -1).join(" ")) || "",
+ },
+ photos: spotifyProfile.photos || [],
+ connectionProfile: {
+ username: email ? email.split("@")[0] : spotifyProfile.id,
+ readableId: spotifyProfile.id,
+ email: email,
+ verified: true, // Assume verified if provided by Spotify
+ },
+ };
+
+ return profile;
+ }
+}
diff --git a/src/providers/spotify/interfaces.ts b/src/providers/spotify/interfaces.ts
new file mode 100644
index 00000000..4e8605d5
--- /dev/null
+++ b/src/providers/spotify/interfaces.ts
@@ -0,0 +1,11 @@
+import { BaseHandlerConfig, BaseProviderConfig } from "../../../src/interfaces";
+
+export interface SpotifyProviderConfig extends BaseProviderConfig {
+ clientId: string;
+ clientSecret: string;
+ callbackUrl: string;
+}
+
+export interface SpotifyHandlerConfig extends BaseHandlerConfig {
+ batchSize: number
+}
\ No newline at end of file
diff --git a/src/providers/spotify/spotify-favourite.ts b/src/providers/spotify/spotify-favourite.ts
new file mode 100644
index 00000000..c824c189
--- /dev/null
+++ b/src/providers/spotify/spotify-favourite.ts
@@ -0,0 +1,138 @@
+import CONFIG from "../../config";
+import {
+ SyncProviderLogEvent,
+ SyncProviderLogLevel,
+ SyncHandlerPosition,
+ SyncResponse,
+ SyncHandlerStatus,
+ ProviderHandlerOption,
+ ConnectionOptionType
+} from "../../interfaces";
+import { SchemaFavourite, SchemaFavouriteContentType, SchemaFavouriteType } from "../../schemas";
+import { SpotifyHandlerConfig } from "./interfaces";
+import AccessDeniedError from "../AccessDeniedError";
+import InvalidTokenError from "../InvalidTokenError";
+import BaseSyncHandler from "../BaseSyncHandler";
+import { Client, UsersController } from "spotify-api-sdk";
+
+const MAX_BATCH_SIZE = 50;
+
+export default class SpotifyFavouriteHandler extends BaseSyncHandler {
+
+ protected config: SpotifyHandlerConfig;
+
+ public getLabel(): string {
+ return "Spotify Favourite Tracks";
+ }
+
+ public getName(): string {
+ return "spotify-favourite";
+ }
+
+ public getSchemaUri(): string {
+ return CONFIG.verida.schemas.FAVORITES;
+ }
+
+ public getProviderApplicationUrl(): string {
+ return "https://spotify.com/";
+ }
+
+ public getOptions(): ProviderHandlerOption[] {
+ return [{
+ id: 'backdate',
+ label: 'Backdate history',
+ type: ConnectionOptionType.ENUM,
+ enumOptions: [{
+ value: '1-month',
+ label: '1 month'
+ }, {
+ value: '3-months',
+ label: '3 months'
+ }, {
+ value: '6-months',
+ label: '6 months'
+ }, {
+ value: '12-months',
+ label: '12 months'
+ }],
+ defaultValue: '3-months'
+ }];
+ }
+
+ /**
+ * Don't use pagination and Item range tracking here
+ *
+ * @param client
+ * @param syncPosition
+ * @returns
+ */
+ public async _sync(
+ client: Client,
+ syncPosition: SyncHandlerPosition
+ ): Promise {
+ const usersController = new UsersController(client);
+
+ try {
+ if (this.config.batchSize > MAX_BATCH_SIZE) {
+ throw new Error(`Batch size (${this.config.batchSize}) is larger than permitted (${MAX_BATCH_SIZE})`);
+ }
+
+ // Fetch results for user's top tracks
+ const response = await usersController.getUsersTopTracks("medium_term", this.config.batchSize);
+
+ const result = await this.buildResults(response.result);
+ const items = result.items;
+
+ if (!items.length) {
+ syncPosition.syncMessage = `Stopping. No results found.`;
+ syncPosition.status = SyncHandlerStatus.ENABLED;
+ } else {
+ syncPosition.syncMessage = `Batch complete (${this.config.batchSize}). No more results.`;
+ }
+
+ return {
+ results: items,
+ position: syncPosition,
+ };
+ } catch (err: any) {
+ if (err.response && err.response.status === 403) {
+ throw new AccessDeniedError(err.message);
+ } else if (err.response && err.response.status === 401) {
+ throw new InvalidTokenError(err.message);
+ }
+
+ throw err;
+ }
+ }
+
+ protected async buildResults(
+ response: any
+ ): Promise<{ items: SchemaFavourite[] }> {
+ const results: SchemaFavourite[] = [];
+
+ for (const track of response.items ?? []) {
+ const trackId = track.id;
+ const name = track.name; // Use track name
+ const popularity = track.popularity ?? 0;
+ const uri = track.externalUrls?.spotify ?? '';
+ const icon = track.album?.images?.[0]?.url ?? ''; // Use album art as icon
+
+ results.push({
+ _id: this.buildItemId(trackId),
+ name: name,
+ icon: icon,
+ uri: uri,
+ description: `Popularity: ${popularity}`,
+ favouriteType: SchemaFavouriteType.FAVOURITE,
+ contentType: SchemaFavouriteContentType.AUDIO,
+ sourceId: trackId,
+ sourceData: track,
+ sourceAccountId: this.provider.getAccountId(),
+ sourceApplication: this.getProviderApplicationUrl(),
+ insertedAt: new Date().toISOString(),
+ });
+ }
+
+ return { items: results };
+ }
+}
diff --git a/src/providers/spotify/spotify-following.ts b/src/providers/spotify/spotify-following.ts
new file mode 100644
index 00000000..f644448c
--- /dev/null
+++ b/src/providers/spotify/spotify-following.ts
@@ -0,0 +1,198 @@
+import CONFIG from "../../config";
+import {
+ SyncProviderLogEvent,
+ SyncProviderLogLevel,
+ SyncHandlerPosition,
+ SyncResponse,
+ SyncHandlerStatus,
+ SyncItemsBreak,
+ ProviderHandlerOption,
+ ConnectionOptionType
+} from "../../interfaces";
+import { SchemaFollowing } from "../../schemas";
+import { ItemsRangeTracker } from "../../helpers/itemsRangeTracker";
+import { SpotifyHandlerConfig } from "./interfaces";
+import AccessDeniedError from "../AccessDeniedError";
+import InvalidTokenError from "../InvalidTokenError";
+import BaseSyncHandler from "../BaseSyncHandler";
+import { Client, ItemType1Enum, UsersController } from "spotify-api-sdk";
+
+const MAX_BATCH_SIZE = 50;
+
+export default class SpotifyFollowing extends BaseSyncHandler {
+
+ protected config: SpotifyHandlerConfig;
+
+ public getLabel(): string {
+ return "Spotify Followed Artists";
+ }
+
+ public getName(): string {
+ return "spotify-following";
+ }
+
+ public getSchemaUri(): string {
+ return CONFIG.verida.schemas.FOLLOWING;
+ }
+
+ public getProviderApplicationUrl(): string {
+ return "https://spotify.com/";
+ }
+
+ public getOptions(): ProviderHandlerOption[] {
+ return [{
+ id: 'backdate',
+ label: 'Backdate history',
+ type: ConnectionOptionType.ENUM,
+ enumOptions: [{
+ value: '1-month',
+ label: '1 month'
+ }, {
+ value: '3-months',
+ label: '3 months'
+ }, {
+ value: '6-months',
+ label: '6 months'
+ }, {
+ value: '12-months',
+ label: '12 months'
+ }],
+ defaultValue: '3-months'
+ }];
+ }
+
+ public async _sync(
+ client: Client,
+ syncPosition: SyncHandlerPosition
+ ): Promise {
+ const usersController = new UsersController(client);
+
+ try {
+ if (this.config.batchSize > MAX_BATCH_SIZE) {
+ throw new Error(`Batch size (${this.config.batchSize}) is larger than permitted (${MAX_BATCH_SIZE})`);
+ }
+
+ const rangeTracker = new ItemsRangeTracker(syncPosition.thisRef);
+ let items: SchemaFollowing[] = [];
+
+ let currentRange = rangeTracker.nextRange();
+ let offset = currentRange.startId ?? currentRange.startId;
+
+ // Fetch initial results
+ const response = await usersController.getFollowed(ItemType1Enum.Artist, offset, this.config.batchSize);
+
+ const result = await this.buildResults(response.result, currentRange.endId);
+ items = result.items;
+
+ // Determine the next offset based on the cursor from the response
+ let nextOffset = response.result.artists?.cursors?.after;
+
+ if (items.length) {
+ rangeTracker.completedRange({
+ startId: offset?.toString(),
+ endId: nextOffset,
+ }, result.breakHit == SyncItemsBreak.ID);
+ } else {
+ rangeTracker.completedRange({
+ startId: undefined,
+ endId: undefined,
+ }, false);
+ }
+
+ currentRange = rangeTracker.nextRange();
+ if (items.length != this.config.batchSize && currentRange.startId) {
+ const backfillOffset = currentRange.startId;
+ const backfillBatchSize = this.config.batchSize - items.length;
+
+ const backfillResponse = await usersController.getFollowed(ItemType1Enum.Artist, backfillOffset, backfillBatchSize);
+
+ const backfillResult = await this.buildResults(backfillResponse.result, currentRange.endId);
+ items = items.concat(backfillResult.items);
+
+ nextOffset = backfillResponse.result.artists?.cursors?.after;
+
+ if (backfillResult.items.length) {
+ rangeTracker.completedRange({
+ startId: backfillOffset?.toString(),
+ endId: nextOffset,
+ }, backfillResult.breakHit == SyncItemsBreak.ID);
+ } else {
+ rangeTracker.completedRange({
+ startId: undefined,
+ endId: undefined,
+ }, backfillResult.breakHit == SyncItemsBreak.ID);
+ }
+ }
+
+ if (!items.length) {
+ syncPosition.syncMessage = `Stopping. No results found.`;
+ syncPosition.status = SyncHandlerStatus.ENABLED;
+ } else {
+ if (items.length != this.config.batchSize && !nextOffset) {
+ syncPosition.syncMessage = `Processed ${items.length} items. Stopping. No more results.`;
+ syncPosition.status = SyncHandlerStatus.ENABLED;
+ } else {
+ syncPosition.syncMessage = `Batch complete (${this.config.batchSize}). More results pending.`;
+ }
+ }
+
+ syncPosition.thisRef = rangeTracker.export();
+
+ return {
+ results: items,
+ position: syncPosition,
+ };
+ } catch (err: any) {
+ if (err.response && err.response.status === 403) {
+ throw new AccessDeniedError(err.message);
+ } else if (err.response && err.response.status === 401) {
+ throw new InvalidTokenError(err.message);
+ }
+
+ throw err;
+ }
+ }
+
+ protected async buildResults(
+ response: any,
+ breakId: string
+ ): Promise<{ items: SchemaFollowing[], breakHit?: SyncItemsBreak }> {
+ const results: SchemaFollowing[] = [];
+ let breakHit: SyncItemsBreak;
+
+ for (const artist of response.artists?.items ?? []) {
+ const artistId = artist.id;
+
+ if (artistId === breakId) {
+ const logEvent: SyncProviderLogEvent = {
+ level: SyncProviderLogLevel.DEBUG,
+ message: `Break ID hit (${breakId})`
+ };
+ this.emit('log', logEvent);
+ breakHit = SyncItemsBreak.ID;
+ break;
+ }
+
+ const name = artist.name;
+ const followers = artist.followers?.total ?? 0;
+ const uri = artist.externalUrls?.spotify ?? '';
+ const icon = artist.images?.[0]?.url ?? '';
+
+ results.push({
+ _id: this.buildItemId(artistId),
+ name: name,
+ icon: icon,
+ uri: uri,
+ description: `${followers} followers`,
+ sourceId: artistId,
+ sourceData: artist,
+ sourceAccountId: this.provider.getAccountId(),
+ sourceApplication: this.getProviderApplicationUrl(),
+ followedTimestamp: new Date().toISOString(),
+ insertedAt: new Date().toISOString(),
+ });
+ }
+
+ return { items: results, breakHit };
+ }
+}
diff --git a/src/providers/spotify/spotify-history.ts b/src/providers/spotify/spotify-history.ts
new file mode 100644
index 00000000..9359fda2
--- /dev/null
+++ b/src/providers/spotify/spotify-history.ts
@@ -0,0 +1,201 @@
+import CONFIG from "../../config";
+import {
+ SyncProviderLogEvent,
+ SyncProviderLogLevel,
+ SyncHandlerPosition,
+ SyncResponse,
+ SyncHandlerStatus,
+ SyncItemsBreak,
+ ProviderHandlerOption,
+ ConnectionOptionType
+} from "../../interfaces";
+import { SchemaHistory, SchemaHistoryActivityType } from "../../schemas";
+import { ItemsRangeTracker } from "../../helpers/itemsRangeTracker";
+import { SpotifyHandlerConfig } from "./interfaces";
+import AccessDeniedError from "../AccessDeniedError";
+import InvalidTokenError from "../InvalidTokenError";
+import BaseSyncHandler from "../BaseSyncHandler";
+import { Client, PlayerController } from "spotify-api-sdk";
+
+const MAX_BATCH_SIZE = 50;
+
+export default class SpotifyPlayHistory extends BaseSyncHandler {
+
+ protected config: SpotifyHandlerConfig;
+
+ public getLabel(): string {
+ return "Spotify Play History";
+ }
+
+ public getName(): string {
+ return "spotify-history";
+ }
+
+ public getSchemaUri(): string {
+ return CONFIG.verida.schemas.HISTORY;
+ }
+
+ public getProviderApplicationUrl(): string {
+ return "https://spotify.com/";
+ }
+
+ public getOptions(): ProviderHandlerOption[] {
+ return [{
+ id: 'backdate',
+ label: 'Backdate history',
+ type: ConnectionOptionType.ENUM,
+ enumOptions: [
+ { value: '1-month', label: '1 month' },
+ { value: '3-months', label: '3 months' },
+ { value: '6-months', label: '6 months' },
+ { value: '12-months', label: '12 months' }
+ ],
+ defaultValue: '3-months'
+ }];
+ }
+
+ public async _sync(
+ client: Client,
+ syncPosition: SyncHandlerPosition
+ ): Promise {
+ const playerController = new PlayerController(client);
+
+ try {
+ if (this.config.batchSize > MAX_BATCH_SIZE) {
+ throw new Error(`Batch size (${this.config.batchSize}) is larger than permitted (${MAX_BATCH_SIZE})`);
+ }
+
+ const rangeTracker = new ItemsRangeTracker(syncPosition.thisRef);
+ let items: SchemaHistory[] = [];
+
+ // Timestamp based
+ let currentRange = rangeTracker.nextRange();
+ let offset = currentRange.startId ?? 0;
+
+ const response = await playerController.getRecentlyPlayed(this.config.batchSize, BigInt(offset));
+
+ const result = await this.buildResults(response.result.items, currentRange.endId);
+ items = result.items;
+
+ let nextOffset = response.result.cursors?.after;
+
+ if (items.length) {
+ rangeTracker.completedRange({
+ startId: offset?.toString(),
+ endId: nextOffset?.toString(),
+ }, result.breakHit == SyncItemsBreak.ID);
+ } else {
+ rangeTracker.completedRange({
+ startId: undefined,
+ endId: undefined,
+ }, false);
+ }
+
+ currentRange = rangeTracker.nextRange();
+ if (items.length != this.config.batchSize && currentRange.startId) {
+ const backfillOffset = currentRange.startId;
+ const backfillBatchSize = this.config.batchSize - items.length;
+
+ const backfillResponse = await playerController.getRecentlyPlayed(backfillBatchSize, BigInt(backfillOffset));
+ const backfillResult = await this.buildResults(backfillResponse.result.items, currentRange.endId);
+ items = items.concat(backfillResult.items);
+
+ nextOffset = backfillResponse.result.cursors?.after;
+
+ if (backfillResult.items.length) {
+ rangeTracker.completedRange({
+ startId: backfillOffset?.toString(),
+ endId: nextOffset?.toString(),
+ }, backfillResult.breakHit == SyncItemsBreak.ID);
+ } else {
+ rangeTracker.completedRange({
+ startId: undefined,
+ endId: undefined,
+ }, backfillResult.breakHit == SyncItemsBreak.ID);
+ }
+ }
+
+ if (!items.length) {
+ syncPosition.syncMessage = `Stopping. No results found.`;
+ syncPosition.status = SyncHandlerStatus.ENABLED;
+ } else {
+ if (items.length != this.config.batchSize && !nextOffset) {
+ syncPosition.syncMessage = `Processed ${items.length} items. Stopping. No more results.`;
+ syncPosition.status = SyncHandlerStatus.ENABLED;
+ } else {
+ syncPosition.syncMessage = `Batch complete (${this.config.batchSize}). More results pending.`;
+ }
+ }
+
+ syncPosition.thisRef = rangeTracker.export();
+
+ return {
+ results: items,
+ position: syncPosition,
+ };
+ } catch (err: any) {
+ if (err.response) {
+ if (err.response.status === 403) {
+ throw new AccessDeniedError(err.message);
+ } else if (err.response.status === 401) {
+ throw new InvalidTokenError(err.message);
+ }
+ throw new Error(`Unexpected error: ${err.response.status}`);
+ }
+ throw err; // Re-throw non-HTTP errors
+ }
+ }
+
+ protected async buildResults(
+ items: any[],
+ breakTimeStamp: string
+ ): Promise<{ items: SchemaHistory[], breakHit?: SyncItemsBreak }> {
+ const results: SchemaHistory[] = [];
+ let breakHit: SyncItemsBreak;
+
+ for (const playHistory of items) {
+ const trackId = playHistory.track.id;
+ if (!trackId) {
+ console.warn('Missing track ID:', playHistory.track);
+ continue;
+ }
+
+ const playedAt = playHistory.playedAt
+
+ if (breakTimeStamp && playedAt <= new Date(breakTimeStamp).toISOString()) {
+ const logEvent: SyncProviderLogEvent = {
+ level: SyncProviderLogLevel.DEBUG,
+ message: `Break timestamp hit (${new Date(breakTimeStamp).toISOString()})`
+ };
+ this.emit('log', logEvent);
+ breakHit = SyncItemsBreak.TIMESTAMP;
+ break;
+ }
+
+ const trackName = playHistory.track.name || 'Unknown Track';
+ const durationMs = playHistory.track.durationMs || 0;
+ const artists = playHistory.track.artists?.map((artist: any) => artist?.name || 'Unknown Artist').join(", ") || 'Unknown Artist';
+ const uri = playHistory.track.externalUrls?.spotify || playHistory.track.uri || '';
+ const icon = playHistory.track.album?.images?.[0]?.url || '';
+
+ results.push({
+ _id: this.buildItemId(trackId),
+ name: `${trackName} by ${artists}`,
+ icon: icon,
+ url: uri,
+ sourceId: trackId,
+ sourceData: playHistory.track,
+ sourceAccountId: this.provider.getAccountId(),
+ sourceApplication: this.getProviderApplicationUrl(),
+ insertedAt: new Date().toISOString(),
+ timestamp: playedAt,
+ duration: Number(durationMs) / 1000,
+ activityType: SchemaHistoryActivityType.LISTENING
+ });
+
+ }
+
+ return { items: results, breakHit };
+ }
+}
+
diff --git a/src/providers/spotify/spotify-playlist.ts b/src/providers/spotify/spotify-playlist.ts
new file mode 100644
index 00000000..96845a9e
--- /dev/null
+++ b/src/providers/spotify/spotify-playlist.ts
@@ -0,0 +1,183 @@
+import CONFIG from "../../config";
+import {
+ SyncHandlerPosition,
+ SyncResponse,
+ SyncHandlerStatus,
+ ProviderHandlerOption
+} from "../../interfaces";
+import { SchemaPlaylist, SchemaPlaylistType, SchemaSpotifyTrack } from "../../schemas";
+import { SpotifyHandlerConfig } from "./interfaces";
+import AccessDeniedError from "../AccessDeniedError";
+import InvalidTokenError from "../InvalidTokenError";
+import BaseSyncHandler from "../BaseSyncHandler";
+import { Client, PlaylistsController } from "spotify-api-sdk";
+import { ItemsRangeTracker } from "../../helpers/itemsRangeTracker";
+import { Person } from "../google/interfaces";
+
+export default class SpotifyPlaylistHandler extends BaseSyncHandler {
+
+ protected config: SpotifyHandlerConfig;
+
+ public getLabel(): string {
+ return "Spotify Playlist";
+ }
+
+ public getName(): string {
+ return "spotify-playlist";
+ }
+
+ public getSchemaUri(): string {
+ return CONFIG.verida.schemas.PLAYLIST;
+ }
+
+ public getProviderApplicationUrl(): string {
+ return "https://spotify.com/";
+ }
+
+ public getOptions(): ProviderHandlerOption[] {
+ return [];
+ }
+
+ public async _sync(
+ client: Client,
+ syncPosition: SyncHandlerPosition
+ ): Promise {
+ const playlistsController = new PlaylistsController(client);
+
+ try {
+ const limit = this.config.batchSize;
+
+ const rangeTracker = new ItemsRangeTracker(syncPosition.thisRef);
+ let items: SchemaPlaylist[] = [];
+
+ let currentRange = rangeTracker.nextRange();
+ let offset = currentRange.startId ? currentRange.startId : '0';
+
+ const response = await playlistsController.getAListOfCurrentUsersPlaylists(limit, parseInt(offset));
+
+ const result = await this.buildResults(response.result.items);
+ items = result.items;
+
+ let nextOffset = response.result.offset + response.result.total;
+
+ if (items.length) {
+ rangeTracker.completedRange({
+ startId: offset.toString(),
+ endId: nextOffset?.toString(),
+ }, false);
+ } else {
+ rangeTracker.completedRange({
+ startId: undefined,
+ endId: undefined,
+ }, false);
+ }
+
+ currentRange = rangeTracker.nextRange();
+ if (items.length != limit && currentRange.startId) {
+ const backfillOffset = currentRange.startId;
+ const backfillBatchSize = limit - items.length;
+
+ const backfillResponse = await playlistsController.getAListOfCurrentUsersPlaylists(backfillBatchSize, parseInt(backfillOffset));
+ const backfillResult = await this.buildResults(backfillResponse.result.items);
+ items = items.concat(backfillResult.items);
+
+ nextOffset = backfillResponse.result.offset + backfillResponse.result.limit;
+
+ if (backfillResult.items.length) {
+ rangeTracker.completedRange({
+ startId: backfillOffset?.toString(),
+ endId: nextOffset?.toString(),
+ }, false);
+ } else {
+ rangeTracker.completedRange({
+ startId: undefined,
+ endId: undefined,
+ }, false);
+ }
+ }
+
+ if (!items.length) {
+ syncPosition.syncMessage = `Stopping. No playlists found.`;
+ syncPosition.status = SyncHandlerStatus.ENABLED;
+ } else {
+ if (items.length != limit && !nextOffset) {
+ syncPosition.syncMessage = `Processed ${items.length} playlists. Stopping. No more results.`;
+ syncPosition.status = SyncHandlerStatus.ENABLED;
+ } else {
+ syncPosition.syncMessage = `Batch complete (${limit}). More results pending.`;
+ }
+ }
+
+ syncPosition.thisRef = rangeTracker.export();
+
+ return {
+ results: items,
+ position: syncPosition,
+ };
+ } catch (err: any) {
+ if (err.response) {
+ if (err.response.status === 403) {
+ throw new AccessDeniedError(err.message);
+ } else if (err.response.status === 401) {
+ throw new InvalidTokenError(err.message);
+ }
+ throw new Error(`Unexpected error: ${err.response.status}`);
+ }
+ throw err; // Re-throw non-HTTP errors
+ }
+ }
+
+ protected async buildResults(
+ items: any[]
+ ): Promise<{ items: SchemaPlaylist[] }> {
+ const results: SchemaPlaylist[] = [];
+
+ for (const playlist of items) {
+ const playlistId = playlist.id;
+ const playlistName = playlist.name;
+ const externalUrl = playlist.external_urls?.spotify ?? '';
+ const icon = playlist.images?.[0]?.url ?? '';
+ const owner: Person = playlist.owner;
+
+ const tracks: SchemaSpotifyTrack[] = await this.getPlaylistTracks(playlist.tracks.href, this.connection.accessToken);
+
+ results.push({
+ _id: this.buildItemId(playlistId),
+ sourceAccountId: this.provider.getAccountId(),
+ sourceApplication: this.getProviderApplicationUrl(),
+ sourceId: playlistId,
+ sourceData: playlist,
+ schema: CONFIG.verida.schemas.PLAYLIST,
+ name: playlistName,
+ icon: icon,
+ uri: 'externalUrl',
+ type: SchemaPlaylistType.AUDIO,
+ tracks: tracks,
+ owner: owner,
+ insertedAt: new Date().toISOString()
+ });
+ }
+
+ return { items: results };
+ }
+
+ private async getPlaylistTracks(playlistTracksUrl: string, accessToken: string): Promise {
+ const response = await fetch(playlistTracksUrl, {
+ headers: {
+ 'Authorization': `Bearer ${accessToken}`
+ }
+ });
+
+ const data = await response.json();
+
+ return data.items.map((item: any) => {
+ const track = item.track;
+ return {
+ id: track.id,
+ title: track.name,
+ duration: track.duration_ms,
+ url: track.external_urls?.spotify ?? ''
+ };
+ });
+ }
+}
diff --git a/src/schemas.ts b/src/schemas.ts
index a1f9e1d6..360034a6 100644
--- a/src/schemas.ts
+++ b/src/schemas.ts
@@ -156,3 +156,41 @@ export interface SchemaEvent extends SchemaRecord {
attachments?: CalendarAttachment[]
}
+
+export enum SchemaHistoryActivityType {
+ LISTENING = "listening",
+ WATCHING = "watching",
+ BROWSING = "browsing",
+ READING = "reading"
+}
+
+export interface SchemaHistory extends SchemaRecord {
+ timestamp: string; // The time when the activity occurred
+ activityType: SchemaHistoryActivityType;
+ url?: string; // URL associated with the activity
+ duration?: number; // Duration of the activity in seconds
+}
+
+// Enum for the type of playlist (audio or video)
+export enum SchemaPlaylistType {
+ AUDIO = "audio",
+ VIDEO = "video"
+}
+
+// Interface representing a Track or Video in the playlist
+export interface SchemaSpotifyTrack {
+ id: string; // Unique identifier for the track/video
+ title: string; // Title of the track/video
+ artist?: string; // Name of the artist or content creator
+ album?: string; // Album name (if applicable)
+ thumbnail?: string; // URL of the thumbnail image
+ duration?: number; // Duration of the track/video in milliseconds
+ url?: string; // Direct URL to the track/video
+ type: SchemaPlaylistType; // Type of the track (audio or video)
+}
+
+export interface SchemaPlaylist extends SchemaRecord {
+ type: SchemaPlaylistType; // Type of the playlist (audio or video)
+ owner: Person; // Owner of the playlist
+ tracks: SchemaSpotifyTrack[]; // Array of track or video items in the playlist
+}
diff --git a/src/serverconfig.example.json b/src/serverconfig.example.json
index a94fd73e..27e04791 100644
--- a/src/serverconfig.example.json
+++ b/src/serverconfig.example.json
@@ -33,7 +33,9 @@
"CHAT_GROUP": "https://common.schemas.verida.io/social/chat/group/v0.1.0/schema.json",
"CHAT_MESSAGE": "https://common.schemas.verida.io/social/chat/message/v0.1.0/schema.json",
"CALENDAR": "https://common.schemas.verida.io/social/calendar/v0.1.0/schema.json",
- "EVENT": "https://common.schemas.verida.io/social/event/v0.1.0/schema.json"
+ "EVENT": "https://common.schemas.verida.io/social/event/v0.1.0/schema.json",
+ "PLAYLIST": "https://common.schemas.verida.io/media/playlist/v0.1.0/schema.json",
+ "HISTORY": "https://common.schemas.verida.io/activity/history/v0.1.0/schema.json"
},
"llms": {
"bedrockEndpoint": "",
@@ -119,9 +121,13 @@
"description": "Sync your Slack channels and DMs"
},
"spotify": {
- "status": "upcoming",
+ "status": "active",
"label": "Spotify",
- "description": "Sync your playlists and listening history"
+ "description": "Sync your playlists and listening history",
+ "clientId": "",
+ "clientSecret": "",
+ "batchSize": 50,
+ "maxSyncLoops": 1
},
"google": {
"label": "Google",
diff --git a/src/web/developer/data/data.js b/src/web/developer/data/data.js
index ec9202a0..092c1569 100644
--- a/src/web/developer/data/data.js
+++ b/src/web/developer/data/data.js
@@ -22,7 +22,9 @@ $(document).ready(function() {
"Chat Message": "https://common.schemas.verida.io/social/chat/message/v0.1.0/schema.json",
"Files": "https://common.schemas.verida.io/file/v0.1.0/schema.json",
"Calendar": "https://common.schemas.verida.io/social/calendar/v0.1.0/schema.json",
- "Event": "https://common.schemas.verida.io/social/event/v0.1.0/schema.json"
+ "Event": "https://common.schemas.verida.io/social/event/v0.1.0/schema.json",
+ "Playlist": "https://common.schemas.verida.io/media/playlist/v0.1.0/schema.json",
+ "History": "https://common.schemas.verida.io/activity/history/v0.1.0/schema.json"
};
// Load Verida Key and Schema from local storage
diff --git a/tests/providers/spotify/spotify-favourite.tests.ts b/tests/providers/spotify/spotify-favourite.tests.ts
new file mode 100644
index 00000000..9d4e95be
--- /dev/null
+++ b/tests/providers/spotify/spotify-favourite.tests.ts
@@ -0,0 +1,253 @@
+const assert = require("assert");
+import CONFIG from "../../../src/config";
+import {
+ BaseProviderConfig,
+ Connection,
+ SyncHandlerPosition,
+ SyncHandlerStatus
+} from "../../../src/interfaces";
+import Providers from "../../../src/providers";
+import CommonUtils, { NetworkInstance } from "../../common.utils";
+
+import SpotifyFavourite from "../../../src/providers/spotify/spotify-favourite";
+import BaseProvider from "../../../src/providers/BaseProvider";
+import { CommonTests, GenericTestConfig } from "../../common.tests";
+import { SchemaFavourite } from "../../../src/schemas";
+import { SpotifyHandlerConfig } from "../../../src/providers/spotify/interfaces";
+
+const providerId = "spotify";
+let network: NetworkInstance;
+let connection: Connection;
+let provider: BaseProvider;
+let handlerName = "spotify-favourite";
+let testConfig: GenericTestConfig;
+
+let providerConfig: Omit = {};
+let handlerConfig: SpotifyHandlerConfig = {
+ batchSize: 20,
+ maxBatchSize: 50
+};
+
+describe(`${providerId} favourite tests`, function () {
+ this.timeout(100000);
+
+ this.beforeAll(async function () {
+ network = await CommonUtils.getNetwork();
+ connection = await CommonUtils.getConnection(providerId);
+ provider = Providers(providerId, network.context, connection);
+
+ testConfig = {
+ idPrefix: `${provider.getProviderId()}-${connection.profile.id}`,
+ timeOrderAttribute: "insertedAt",
+ batchSizeLimitAttribute: "batchSize",
+ };
+ });
+
+ describe(`Fetch ${providerId} data`, () => {
+ it(`Can pass basic tests: ${handlerName}`, async () => {
+ const { api, handler, provider } = await CommonTests.buildTestObjects(
+ providerId,
+ SpotifyFavourite,
+ providerConfig,
+ connection
+ );
+
+ handler.setConfig(handlerConfig);
+
+ try {
+ const syncPosition: SyncHandlerPosition = {
+ _id: `${providerId}-${handlerName}`,
+ providerId,
+ handlerId: handler.getId(),
+ accountId: provider.getAccountId(),
+ status: SyncHandlerStatus.ENABLED,
+ };
+
+ // First batch
+ const response = await handler._sync(api, syncPosition);
+ const results = response.results;
+
+ // Basic assertions
+ assert.ok(results && results.length, "Have results returned");
+ assert.ok(results.length <= handlerConfig.batchSize, "Results respect batch size limit");
+
+ // Check first item structure
+ CommonTests.checkItem(results[0], handler, provider);
+
+ // Verify sync status
+ assert.equal(
+ SyncHandlerStatus.SYNCING,
+ response.position.status,
+ "Sync is active"
+ );
+
+ // Second batch
+ const secondBatchResponse = await handler._sync(api, response.position);
+ const secondBatchResults = secondBatchResponse.results;
+
+ // Verify second batch
+ assert.ok(secondBatchResults && secondBatchResults.length, "Have second batch results");
+ assert.ok(secondBatchResults.length <= handlerConfig.batchSize, "Second batch respects size limit");
+
+ } catch (err) {
+ await provider.close();
+ throw err;
+ }
+ });
+
+ it(`Should have valid favourite data structure`, async () => {
+ const { api, handler, provider } = await CommonTests.buildTestObjects(
+ providerId,
+ SpotifyFavourite,
+ providerConfig,
+ connection
+ );
+ handler.setConfig(handlerConfig);
+
+ const syncPosition: SyncHandlerPosition = {
+ _id: `${providerId}-${handlerName}`,
+ providerId,
+ handlerId: handler.getId(),
+ accountId: provider.getAccountId(),
+ status: SyncHandlerStatus.ENABLED,
+ };
+
+ const response = await handler._sync(api, syncPosition);
+ const results = response.results;
+
+ // Test first result has required fields
+ const firstFavourite = results[0];
+ assert.ok(firstFavourite.name, "Has name");
+ assert.ok(firstFavourite.sourceId, "Has sourceId");
+ assert.ok(firstFavourite.sourceData, "Has sourceData");
+ assert.ok(firstFavourite.insertedAt, "Has insertedAt timestamp");
+ });
+
+ it(`Should ensure second batch items aren't in the first batch`, async () => {
+ const { api, handler, provider } = await CommonTests.buildTestObjects(
+ providerId,
+ SpotifyFavourite,
+ providerConfig,
+ connection
+ );
+ handler.setConfig(handlerConfig);
+
+ const firstBatchResponse = await handler._sync(api, {
+ _id: `${providerId}-${handlerName}`,
+ providerId,
+ handlerId: handler.getId(),
+ accountId: provider.getAccountId(),
+ status: SyncHandlerStatus.ENABLED,
+ });
+
+ const firstBatchItems = firstBatchResponse.results;
+
+ const secondBatchResponse = await handler._sync(api, firstBatchResponse.position);
+ const secondBatchItems = secondBatchResponse.results;
+
+ const firstBatchIds = firstBatchItems.map(item => item.sourceId);
+ const secondBatchIds = secondBatchItems.map(item => item.sourceId);
+
+ const intersection = firstBatchIds.filter(id => secondBatchIds.includes(id));
+ assert.equal(intersection.length, 0, "No overlapping items between batches");
+ });
+
+ it(`Should handle different time ranges`, async () => {
+ const { api, handler, provider } = await CommonTests.buildTestObjects(
+ providerId,
+ SpotifyFavourite,
+ providerConfig,
+ connection
+ );
+
+ const timeRanges = ['short_term', 'medium_term', 'long_term'];
+
+ for (const timeRange of timeRanges) {
+ handler.setConfig({
+ ...handlerConfig,
+ timeRange
+ });
+
+ const syncPosition: SyncHandlerPosition = {
+ _id: `${providerId}-${handlerName}-${timeRange}`,
+ providerId,
+ handlerId: handler.getId(),
+ accountId: provider.getAccountId(),
+ status: SyncHandlerStatus.ENABLED,
+ };
+
+ const response = await handler._sync(api, syncPosition);
+ const results = response.results;
+
+ assert.ok(results.length > 0, `Got results for ${timeRange} time range`);
+ }
+ });
+
+ it(`Should include both track and artist details`, async () => {
+ const { api, handler, provider } = await CommonTests.buildTestObjects(
+ providerId,
+ SpotifyFavourite,
+ providerConfig,
+ connection
+ );
+ handler.setConfig(handlerConfig);
+
+ const syncPosition: SyncHandlerPosition = {
+ _id: `${providerId}-${handlerName}`,
+ providerId,
+ handlerId: handler.getId(),
+ accountId: provider.getAccountId(),
+ status: SyncHandlerStatus.ENABLED,
+ };
+
+ const response = await handler._sync(api, syncPosition);
+ const results = response.results;
+
+ results.forEach(favourite => {
+ // Check track details
+ assert.ok(favourite.name, "Has track name");
+ });
+ });
+
+ it(`Should handle pagination correctly`, async () => {
+ const { api, handler, provider } = await CommonTests.buildTestObjects(
+ providerId,
+ SpotifyFavourite,
+ providerConfig,
+ connection
+ );
+
+ // Set a small batch size to force pagination
+ handler.setConfig({
+ ...handlerConfig,
+ batchSize: 5
+ });
+
+ const syncPosition: SyncHandlerPosition = {
+ _id: `${providerId}-${handlerName}`,
+ providerId,
+ handlerId: handler.getId(),
+ accountId: provider.getAccountId(),
+ status: SyncHandlerStatus.ENABLED,
+ };
+
+ // Get first batch
+ const firstResponse = await handler._sync(api, syncPosition);
+ const firstResults = firstResponse.results;
+
+ // Get second batch
+ const secondResponse = await handler._sync(api, firstResponse.position);
+ const secondResults = secondResponse.results;
+
+ // Verify pagination
+ assert.equal(firstResults.length, 5, "First batch has correct size");
+ assert.ok(secondResults.length > 0, "Second batch has results");
+ assert.ok(firstResponse.position.thisRef, "Has pagination reference");
+ });
+ });
+
+ this.afterAll(async function () {
+ const { context } = await CommonUtils.getNetwork();
+ await context.close();
+ });
+});
\ No newline at end of file
diff --git a/tests/providers/spotify/spotify-following.tests.ts b/tests/providers/spotify/spotify-following.tests.ts
new file mode 100644
index 00000000..d84cd1e0
--- /dev/null
+++ b/tests/providers/spotify/spotify-following.tests.ts
@@ -0,0 +1,174 @@
+const assert = require("assert");
+import CONFIG from "../../../src/config";
+import {
+ BaseProviderConfig,
+ Connection,
+ SyncHandlerPosition,
+ SyncHandlerStatus
+} from "../../../src/interfaces";
+import Providers from "../../../src/providers";
+import CommonUtils, { NetworkInstance } from "../../common.utils";
+
+import SpotifyFollowing from "../../../src/providers/spotify/spotify-following";
+import BaseProvider from "../../../src/providers/BaseProvider";
+import { CommonTests, GenericTestConfig } from "../../common.tests";
+import { SchemaFollowing } from "../../../src/schemas";
+import { SpotifyHandlerConfig } from "../../../src/providers/spotify/interfaces";
+
+const providerId = "spotify";
+let network: NetworkInstance;
+let connection: Connection;
+let provider: BaseProvider;
+let handlerName = "spotify-following";
+let testConfig: GenericTestConfig;
+
+let providerConfig: Omit = {};
+let handlerConfig: SpotifyHandlerConfig = {
+ batchSize: 20,
+ maxBatchSize: 50
+};
+
+describe(`${providerId} following tests`, function () {
+ this.timeout(100000);
+
+ this.beforeAll(async function () {
+ network = await CommonUtils.getNetwork();
+ connection = await CommonUtils.getConnection(providerId);
+ provider = Providers(providerId, network.context, connection);
+
+ testConfig = {
+ idPrefix: `${provider.getProviderId()}-${connection.profile.id}`,
+ timeOrderAttribute: "insertedAt",
+ batchSizeLimitAttribute: "batchSize",
+ };
+ });
+
+ describe(`Fetch ${providerId} data`, () => {
+ it(`Can pass basic tests: ${handlerName}`, async () => {
+ const { api, handler, provider } = await CommonTests.buildTestObjects(
+ providerId,
+ SpotifyFollowing,
+ providerConfig,
+ connection
+ );
+
+ handler.setConfig(handlerConfig);
+
+ try {
+ const syncPosition: SyncHandlerPosition = {
+ _id: `${providerId}-${handlerName}`,
+ providerId,
+ handlerId: handler.getId(),
+ accountId: provider.getAccountId(),
+ status: SyncHandlerStatus.ENABLED,
+ };
+
+ // First batch
+ const response = await handler._sync(api, syncPosition);
+ const results = response.results;
+
+ // Basic assertions
+ assert.ok(results && results.length, "Have results returned");
+ assert.ok(results.length <= handlerConfig.batchSize, "Results respect batch size limit");
+
+ // Check first item structure
+ CommonTests.checkItem(results[0], handler, provider);
+
+ // Verify sync status
+ assert.equal(
+ SyncHandlerStatus.SYNCING,
+ response.position.status,
+ "Sync is active"
+ );
+
+ // Second batch
+ const secondBatchResponse = await handler._sync(api, response.position);
+ const secondBatchResults = secondBatchResponse.results;
+
+ // Verify second batch
+ assert.ok(secondBatchResults && secondBatchResults.length, "Have second batch results");
+ assert.ok(secondBatchResults.length <= handlerConfig.batchSize, "Second batch respects size limit");
+
+ // Check for unique items between batches
+ const firstBatchIds = results.map(item => item.sourceId);
+ const secondBatchIds = secondBatchResults.map(item => item.sourceId);
+ const intersection = firstBatchIds.filter(id => secondBatchIds.includes(id));
+ assert.equal(intersection.length, 0, "No overlapping items between batches");
+
+ } catch (err) {
+ await provider.close();
+ throw err;
+ }
+ });
+
+ it(`Should have valid following data structure`, async () => {
+ const { api, handler, provider } = await CommonTests.buildTestObjects(
+ providerId,
+ SpotifyFollowing,
+ providerConfig,
+ connection
+ );
+ handler.setConfig(handlerConfig);
+
+ const syncPosition: SyncHandlerPosition = {
+ _id: `${providerId}-${handlerName}`,
+ providerId,
+ handlerId: handler.getId(),
+ accountId: provider.getAccountId(),
+ status: SyncHandlerStatus.ENABLED,
+ };
+
+ const response = await handler._sync(api, syncPosition);
+ const results = response.results;
+
+ // Test first result has required fields
+ const firstResult = results[0];
+ assert.ok(firstResult.name, "Has name");
+ assert.ok(firstResult.sourceId, "Has sourceId");
+ assert.ok(firstResult.sourceData, "Has sourceData");
+ assert.ok(firstResult.insertedAt, "Has insertedAt timestamp");
+ });
+
+
+ it(`Should handle pagination correctly`, async () => {
+ const { api, handler, provider } = await CommonTests.buildTestObjects(
+ providerId,
+ SpotifyFollowing,
+ providerConfig,
+ connection
+ );
+
+ // Set a small batch size to force pagination
+ handler.setConfig({
+ ...handlerConfig,
+ batchSize: 5
+ });
+
+ const syncPosition: SyncHandlerPosition = {
+ _id: `${providerId}-${handlerName}`,
+ providerId,
+ handlerId: handler.getId(),
+ accountId: provider.getAccountId(),
+ status: SyncHandlerStatus.ENABLED,
+ };
+
+ // Get first batch
+ const firstResponse = await handler._sync(api, syncPosition);
+ const firstResults = firstResponse.results;
+
+ // Get second batch
+ const secondResponse = await handler._sync(api, firstResponse.position);
+ const secondResults = secondResponse.results;
+
+ // Verify pagination
+ assert.equal(firstResults.length, 5, "First batch has correct size");
+ assert.ok(secondResults.length > 0, "Second batch has results");
+ assert.ok(firstResponse.position.thisRef, "Has pagination reference");
+ });
+ });
+
+ this.afterAll(async function () {
+ const { context } = await CommonUtils.getNetwork();
+ await context.close();
+ });
+});
\ No newline at end of file
diff --git a/tests/providers/spotify/spotify-history.tests.ts b/tests/providers/spotify/spotify-history.tests.ts
new file mode 100644
index 00000000..ea7e1b24
--- /dev/null
+++ b/tests/providers/spotify/spotify-history.tests.ts
@@ -0,0 +1,155 @@
+const assert = require("assert");
+import CONFIG from "../../../src/config";
+import {
+ BaseProviderConfig,
+ Connection,
+ SyncHandlerPosition,
+ SyncHandlerStatus
+} from "../../../src/interfaces";
+import Providers from "../../../src/providers";
+import CommonUtils, { NetworkInstance } from "../../common.utils";
+
+import SpotifyPlayHistory from "../../../src/providers/spotify/spotify-history";
+import BaseProvider from "../../../src/providers/BaseProvider";
+import { CommonTests, GenericTestConfig } from "../../common.tests";
+import { SchemaHistory } from "../../../src/schemas";
+import { SpotifyHandlerConfig } from "../../../src/providers/spotify/interfaces";
+
+const providerId = "spotify";
+let network: NetworkInstance;
+let connection: Connection;
+let provider: BaseProvider;
+let handlerName = "spotify-history";
+let testConfig: GenericTestConfig;
+
+let providerConfig: Omit = {};
+let handlerConfig: SpotifyHandlerConfig = {
+ batchSize: 20,
+ maxBatchSize: 50
+};
+
+describe(`${providerId} play history tests`, function () {
+ this.timeout(100000);
+
+ this.beforeAll(async function () {
+ network = await CommonUtils.getNetwork();
+ connection = await CommonUtils.getConnection(providerId);
+ provider = Providers(providerId, network.context, connection);
+
+ testConfig = {
+ idPrefix: `${provider.getProviderId()}-${connection.profile.id}`,
+ timeOrderAttribute: "timestamp",
+ batchSizeLimitAttribute: "batchSize",
+ };
+ });
+
+ describe(`Fetch ${providerId} data`, () => {
+ it(`Can pass basic tests: ${handlerName}`, async () => {
+ const { api, handler, provider } = await CommonTests.buildTestObjects(
+ providerId,
+ SpotifyPlayHistory,
+ providerConfig,
+ connection
+ );
+
+ handler.setConfig(handlerConfig);
+
+ try {
+ const syncPosition: SyncHandlerPosition = {
+ _id: `${providerId}-${handlerName}`,
+ providerId,
+ handlerId: handler.getId(),
+ accountId: provider.getAccountId(),
+ status: SyncHandlerStatus.ENABLED,
+ };
+
+ const response = await handler._sync(api, syncPosition);
+ const results = response.results;
+
+ assert.ok(results && results.length, "Have results returned");
+ assert.ok(results.length <= handlerConfig.batchSize, "Results respect batch size limit");
+
+ CommonTests.checkItem(results[0], handler, provider);
+
+ assert.equal(
+ SyncHandlerStatus.SYNCING,
+ response.position.status,
+ "Sync is active"
+ );
+
+ // Test second batch
+ const secondBatchResponse = await handler._sync(api, response.position);
+ const secondBatchResults = secondBatchResponse.results;
+
+ assert.ok(secondBatchResults && secondBatchResults.length, "Have second batch results");
+ assert.ok(secondBatchResults.length <= handlerConfig.batchSize, "Second batch respects size limit");
+
+ } catch (err) {
+ await provider.close();
+ throw err;
+ }
+ });
+
+ it(`Should process most recent plays first`, async () => {
+ const { api, handler, provider } = await CommonTests.buildTestObjects(
+ providerId,
+ SpotifyPlayHistory,
+ providerConfig,
+ connection
+ );
+ handler.setConfig(handlerConfig);
+
+ const syncPosition: SyncHandlerPosition = {
+ _id: `${providerId}-${handlerName}`,
+ providerId,
+ handlerId: handler.getId(),
+ accountId: provider.getAccountId(),
+ status: SyncHandlerStatus.ENABLED,
+ };
+
+ const response = await handler._sync(api, syncPosition);
+ const results = response.results;
+
+ const timestamps = results.map((item) => new Date(item.timestamp).getTime());
+ const isSortedDescending = timestamps.every(
+ (val, i, arr) => i === 0 || arr[i - 1] >= val
+ );
+
+ assert.ok(isSortedDescending, "Play history is processed from most recent to oldest");
+ });
+
+ it(`Should ensure second batch items aren't in the first batch`, async () => {
+ const { api, handler, provider } = await CommonTests.buildTestObjects(
+ providerId,
+ SpotifyPlayHistory,
+ providerConfig,
+ connection
+ );
+ handler.setConfig(handlerConfig);
+
+ const firstBatchResponse = await handler._sync(api, {
+ _id: `${providerId}-${handlerName}`,
+ providerId,
+ handlerId: handler.getId(),
+ accountId: provider.getAccountId(),
+ status: SyncHandlerStatus.ENABLED,
+ });
+
+ const firstBatchItems = firstBatchResponse.results;
+
+ const secondBatchResponse = await handler._sync(api, firstBatchResponse.position);
+ const secondBatchItems = secondBatchResponse.results;
+
+ const firstBatchIds = firstBatchItems.map((item) => item.sourceId);
+ const secondBatchIds = secondBatchItems.map((item) => item.sourceId);
+
+ const intersection = firstBatchIds.filter((id) => secondBatchIds.includes(id));
+ assert.equal(intersection.length, 0, "No overlapping items between batches");
+ });
+ });
+
+ this.afterAll(async function () {
+ const { context } = await CommonUtils.getNetwork();
+ await context.close();
+ });
+});
\ No newline at end of file
diff --git a/tests/providers/spotify/spotify-playlist.tests.ts b/tests/providers/spotify/spotify-playlist.tests.ts
new file mode 100644
index 00000000..a1537907
--- /dev/null
+++ b/tests/providers/spotify/spotify-playlist.tests.ts
@@ -0,0 +1,222 @@
+const assert = require("assert");
+import CONFIG from "../../../src/config";
+import {
+ BaseProviderConfig,
+ Connection,
+ SyncHandlerPosition,
+ SyncHandlerStatus
+} from "../../../src/interfaces";
+import Providers from "../../../src/providers";
+import CommonUtils, { NetworkInstance } from "../../common.utils";
+
+import SpotifyPlaylist from "../../../src/providers/spotify/spotify-playlist";
+import BaseProvider from "../../../src/providers/BaseProvider";
+import { CommonTests, GenericTestConfig } from "../../common.tests";
+import { SchemaPlaylist } from "../../../src/schemas";
+import { SpotifyHandlerConfig } from "../../../src/providers/spotify/interfaces";
+
+const providerId = "spotify";
+let network: NetworkInstance;
+let connection: Connection;
+let provider: BaseProvider;
+let handlerName = "spotify-playlist";
+let testConfig: GenericTestConfig;
+
+let providerConfig: Omit = {};
+let handlerConfig: SpotifyHandlerConfig = {
+ batchSize: 20,
+ maxBatchSize: 50
+};
+
+describe(`${providerId} playlist tests`, function () {
+ this.timeout(100000);
+
+ this.beforeAll(async function () {
+ network = await CommonUtils.getNetwork();
+ connection = await CommonUtils.getConnection(providerId);
+ provider = Providers(providerId, network.context, connection);
+
+ testConfig = {
+ idPrefix: `${provider.getProviderId()}-${connection.profile.id}`,
+ timeOrderAttribute: "insertedAt",
+ batchSizeLimitAttribute: "batchSize",
+ };
+ });
+
+ describe(`Fetch ${providerId} data`, () => {
+ it(`Can pass basic tests: ${handlerName}`, async () => {
+ const { api, handler, provider } = await CommonTests.buildTestObjects(
+ providerId,
+ SpotifyPlaylist,
+ providerConfig,
+ connection
+ );
+
+ handler.setConfig(handlerConfig);
+
+ try {
+ const syncPosition: SyncHandlerPosition = {
+ _id: `${providerId}-${handlerName}`,
+ providerId,
+ handlerId: handler.getId(),
+ accountId: provider.getAccountId(),
+ status: SyncHandlerStatus.ENABLED,
+ };
+
+ // First batch
+ const response = await handler._sync(api, syncPosition);
+ const results = response.results;
+
+ // Basic assertions
+ assert.ok(results && results.length, "Have results returned");
+ assert.ok(results.length <= handlerConfig.batchSize, "Results respect batch size limit");
+
+ // Check first item structure
+ CommonTests.checkItem(results[0], handler, provider);
+
+ // Verify sync status
+ assert.equal(
+ SyncHandlerStatus.SYNCING,
+ response.position.status,
+ "Sync is active"
+ );
+
+ // Second batch
+ const secondBatchResponse = await handler._sync(api, response.position);
+ const secondBatchResults = secondBatchResponse.results;
+
+ // Verify second batch
+ assert.ok(secondBatchResults && secondBatchResults.length, "Have second batch results");
+ assert.ok(secondBatchResults.length <= handlerConfig.batchSize, "Second batch respects size limit");
+
+ } catch (err) {
+ await provider.close();
+ throw err;
+ }
+ });
+
+ it(`Should have valid playlist data structure`, async () => {
+ const { api, handler, provider } = await CommonTests.buildTestObjects(
+ providerId,
+ SpotifyPlaylist,
+ providerConfig,
+ connection
+ );
+ handler.setConfig(handlerConfig);
+
+ const syncPosition: SyncHandlerPosition = {
+ _id: `${providerId}-${handlerName}`,
+ providerId,
+ handlerId: handler.getId(),
+ accountId: provider.getAccountId(),
+ status: SyncHandlerStatus.ENABLED,
+ };
+
+ const response = await handler._sync(api, syncPosition);
+ const results = response.results;
+
+ // Test first result has required fields
+ const firstPlaylist = results[0];
+ assert.ok(firstPlaylist.name, "Has name");
+ assert.ok(firstPlaylist.sourceId, "Has sourceId");
+ assert.ok(firstPlaylist.sourceData, "Has sourceData");
+ assert.ok(firstPlaylist.insertedAt, "Has insertedAt timestamp");
+ assert.ok(Array.isArray(firstPlaylist.tracks), "Has tracks array");
+ assert.ok(firstPlaylist.owner, "Has owner information");
+
+ });
+
+ it(`Should ensure second batch items aren't in the first batch`, async () => {
+ const { api, handler, provider } = await CommonTests.buildTestObjects(
+ providerId,
+ SpotifyPlaylist,
+ providerConfig,
+ connection
+ );
+ handler.setConfig(handlerConfig);
+
+ const firstBatchResponse = await handler._sync(api, {
+ _id: `${providerId}-${handlerName}`,
+ providerId,
+ handlerId: handler.getId(),
+ accountId: provider.getAccountId(),
+ status: SyncHandlerStatus.ENABLED,
+ });
+
+ const firstBatchItems = firstBatchResponse.results;
+
+ const secondBatchResponse = await handler._sync(api, firstBatchResponse.position);
+ const secondBatchItems = secondBatchResponse.results;
+
+ const firstBatchIds = firstBatchItems.map(item => item.sourceId);
+ const secondBatchIds = secondBatchItems.map(item => item.sourceId);
+
+ const intersection = firstBatchIds.filter(id => secondBatchIds.includes(id));
+ assert.equal(intersection.length, 0, "No overlapping items between batches");
+ });
+
+ it(`Should fetch playlist tracks correctly`, async () => {
+ const { api, handler, provider } = await CommonTests.buildTestObjects(
+ providerId,
+ SpotifyPlaylist,
+ providerConfig,
+ connection
+ );
+ handler.setConfig(handlerConfig);
+
+ const syncPosition: SyncHandlerPosition = {
+ _id: `${providerId}-${handlerName}`,
+ providerId,
+ handlerId: handler.getId(),
+ accountId: provider.getAccountId(),
+ status: SyncHandlerStatus.ENABLED,
+ };
+
+ const response = await handler._sync(api, syncPosition);
+ const results = response.results;
+
+ // Check tracks for first playlist
+ const firstPlaylist = results[0];
+ assert.ok(Array.isArray(firstPlaylist.tracks), "Tracks is an array");
+
+ if (firstPlaylist.tracks.length > 0) {
+ const firstTrack = firstPlaylist.tracks[0];
+ assert.ok(firstTrack.id, "Track has ID");
+ assert.ok(firstTrack.album, "Track has album info");
+ }
+ });
+
+
+ it(`Should respect playlist ownership`, async () => {
+ const { api, handler, provider } = await CommonTests.buildTestObjects(
+ providerId,
+ SpotifyPlaylist,
+ providerConfig,
+ connection
+ );
+ handler.setConfig(handlerConfig);
+
+ const syncPosition: SyncHandlerPosition = {
+ _id: `${providerId}-${handlerName}`,
+ providerId,
+ handlerId: handler.getId(),
+ accountId: provider.getAccountId(),
+ status: SyncHandlerStatus.ENABLED,
+ };
+
+ const response = await handler._sync(api, syncPosition);
+ const results = response.results;
+
+ results.forEach(playlist => {
+ // Check owner info
+ assert.ok(playlist.owner.displayName, "Playlist has owner name");
+
+ });
+ });
+ });
+
+ this.afterAll(async function () {
+ const { context } = await CommonUtils.getNetwork();
+ await context.close();
+ });
+});
\ No newline at end of file
diff --git a/yarn.lock b/yarn.lock
index 9316efae..b982fd41 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -25,6 +25,120 @@
call-me-maybe "^1.0.1"
js-yaml "^4.1.0"
+"@apimatic/authentication-adapters@^0.5.4":
+ version "0.5.5"
+ resolved "https://registry.yarnpkg.com/@apimatic/authentication-adapters/-/authentication-adapters-0.5.5.tgz#d39b50403eeb10d1cefbc2672bc2ec2bde5f12ca"
+ integrity sha512-+Pb1TVKyrSIEiRKrny0XLWeKn32ecthQlaU21ywx6y//+tm1tJbGIrCmcYt5NSyEfiAMbIKGQAtLCTtPlqvu8w==
+ dependencies:
+ "@apimatic/core-interfaces" "^0.2.7"
+ "@apimatic/http-headers" "^0.3.3"
+ "@apimatic/http-query" "^0.3.3"
+ tslib "^2.1.0"
+
+"@apimatic/axios-client-adapter@^0.3.4":
+ version "0.3.7"
+ resolved "https://registry.yarnpkg.com/@apimatic/axios-client-adapter/-/axios-client-adapter-0.3.7.tgz#74951a3c36bc54c12cada1ace5a5cad8b7a4c869"
+ integrity sha512-zsyUzX9LXCc+HGnpRMieKP3fCENOQFjzrCgmznN2NiyGlCBVOTyr/mL82VGQnNb6DKQMyF02R8oF+EdDHy9J6g==
+ dependencies:
+ "@apimatic/convert-to-stream" "^0.1.3"
+ "@apimatic/core-interfaces" "^0.2.7"
+ "@apimatic/file-wrapper" "^0.3.3"
+ "@apimatic/http-headers" "^0.3.3"
+ "@apimatic/http-query" "^0.3.3"
+ "@apimatic/json-bigint" "^1.2.0"
+ "@apimatic/schema" "^0.7.14"
+ axios "^1.7.4"
+ detect-browser "^5.3.0"
+ detect-node "^2.0.4"
+ form-data "^3.0.0"
+ lodash.flatmap "^4.5.0"
+ tiny-warning "^1.0.3"
+ tslib "^2.1.0"
+
+"@apimatic/convert-to-stream@^0.0.2":
+ version "0.0.2"
+ resolved "https://registry.yarnpkg.com/@apimatic/convert-to-stream/-/convert-to-stream-0.0.2.tgz#7d9942071291011c4576de97619d974face8acaa"
+ integrity sha512-1DRg17ItExfMYsXwjt6WIjJSCgV5RGg3fHPLgYD44/YmiU+7suWj7YfPKKUqmNcnJ/AvMh4lG1+tHrfOT01zXw==
+
+"@apimatic/convert-to-stream@^0.1.3":
+ version "0.1.3"
+ resolved "https://registry.yarnpkg.com/@apimatic/convert-to-stream/-/convert-to-stream-0.1.3.tgz#f16fc970e11dd3ffa22f8b5a123928db1bdcf377"
+ integrity sha512-PzQdEXY0V7CHrJsD0G3YoAZSgVaO5Dweqop0b3S4sB+WdFGkAOiF4Qwl/aZZxsbfOnmEanOAOq+gf+noKQ43eg==
+
+"@apimatic/core-interfaces@^0.2.7":
+ version "0.2.7"
+ resolved "https://registry.yarnpkg.com/@apimatic/core-interfaces/-/core-interfaces-0.2.7.tgz#a72d50b8241ecd6f6e43a9f8b24dec99af9d5fab"
+ integrity sha512-ZGvUL9ka1vuyj/zNwShmqaX38nvLS4QcQOtfuPy/WI65HJv+mdMQxWoHTyjvVq/zVs+YRDgN0UXE2MzkqGoDjQ==
+ dependencies:
+ "@apimatic/file-wrapper" "^0.3.3"
+ tslib "^2.1.0"
+
+"@apimatic/core@^0.10.12":
+ version "0.10.16"
+ resolved "https://registry.yarnpkg.com/@apimatic/core/-/core-0.10.16.tgz#e8ac7f0ef2d71ed5ad390be8e03272fc2c573740"
+ integrity sha512-fSsInj0k4dTuxwiIjNP/y3MrlPpdx1xD2fLBRcAbirVtgVo7yxRROxL4mMLZOhlaE9EAgzOreEiX5yfQGoX/LA==
+ dependencies:
+ "@apimatic/convert-to-stream" "^0.0.2"
+ "@apimatic/core-interfaces" "^0.2.7"
+ "@apimatic/file-wrapper" "^0.3.3"
+ "@apimatic/http-headers" "^0.3.3"
+ "@apimatic/http-query" "^0.3.3"
+ "@apimatic/json-bigint" "^1.2.0"
+ "@apimatic/schema" "^0.7.14"
+ detect-browser "^5.3.0"
+ detect-node "^2.0.4"
+ form-data "^3.0.0"
+ json-ptr "^3.1.0"
+ lodash.defaultsdeep "^4.6.1"
+ lodash.flatmap "^4.5.0"
+ tiny-warning "^1.0.3"
+ tslib "^2.1.0"
+
+"@apimatic/file-wrapper@^0.3.3":
+ version "0.3.3"
+ resolved "https://registry.yarnpkg.com/@apimatic/file-wrapper/-/file-wrapper-0.3.3.tgz#1c393bf453c6c8ff46cc40359848e11674327de4"
+ integrity sha512-XIyiRb7PtbILJCof5RKJdElLiRakUk9DQRBYqzUjp1H1tM9MjKTnPYvKa9Rkel+o/wJ1RcB/IEwPtYhYWzQtow==
+ dependencies:
+ tslib "^2.1.0"
+
+"@apimatic/http-headers@^0.3.3":
+ version "0.3.3"
+ resolved "https://registry.yarnpkg.com/@apimatic/http-headers/-/http-headers-0.3.3.tgz#499e7ed701dcde79a24067dacf27d20d9eee6911"
+ integrity sha512-xfDipaFoYgazLJnaEPeG72TOZOQj1PqhJ8zQgpo4Tsr1D/ggmFyiJnuD7RwSqZXCfiMgcRWN0Xzi2mrk0unhrQ==
+ dependencies:
+ tslib "^2.1.0"
+
+"@apimatic/http-query@^0.3.3":
+ version "0.3.3"
+ resolved "https://registry.yarnpkg.com/@apimatic/http-query/-/http-query-0.3.3.tgz#99bfd2114daa4b7453390dbf368b98ff1b2c9ea9"
+ integrity sha512-omPc56Bv5o/6QL6rcXfCkZfJ/NFl4yD+vVfotfVYeyb4ewGWCD8pc6v6cSOwID2A06pK2wy41ltjiuSkaQ+QIA==
+ dependencies:
+ "@apimatic/file-wrapper" "^0.3.3"
+ tslib "^2.1.0"
+
+"@apimatic/json-bigint@^1.2.0":
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/@apimatic/json-bigint/-/json-bigint-1.2.0.tgz#05223455ec135976fe7a55456c2d21a3bbde56d8"
+ integrity sha512-+bmVzYMdZu0Ya5L+my4FXFUih54OvQA/qlZsFOYdOoostyUuB27UDrVWQs/WVCmS0ADdo5vTU0eeTrrBkHoySw==
+
+"@apimatic/oauth-adapters@^0.4.6":
+ version "0.4.8"
+ resolved "https://registry.yarnpkg.com/@apimatic/oauth-adapters/-/oauth-adapters-0.4.8.tgz#06d68e55269239b020ef321e77fac7f15a60b4ad"
+ integrity sha512-D7CrbJqf9YcgRrUTqqjl16C+lM5h05/BomobJ2m6YpdsKlG3ooi2EsPoSHQxa0GUECE5RoHQ6jiUkEWgD5LZIw==
+ dependencies:
+ "@apimatic/core-interfaces" "^0.2.7"
+ "@apimatic/file-wrapper" "^0.3.3"
+ "@apimatic/http-headers" "^0.3.3"
+ "@apimatic/schema" "^0.7.14"
+ tslib "^2.1.0"
+
+"@apimatic/schema@^0.7.12", "@apimatic/schema@^0.7.14":
+ version "0.7.14"
+ resolved "https://registry.yarnpkg.com/@apimatic/schema/-/schema-0.7.14.tgz#959a3866cc0ff97cbd243997fc362d20c1b9715e"
+ integrity sha512-Z01QCVpUv57HKiQ4NrsKl4CmUWDt1y+qMGoHx+dzyo/H8u/cJbX25JbicsEH6/RPXAwnM172ZWWYQViXeXU40A==
+ dependencies:
+ tslib "^2.1.0"
+
"@aws-crypto/crc32@5.2.0":
version "5.2.0"
resolved "https://registry.yarnpkg.com/@aws-crypto/crc32/-/crc32-5.2.0.tgz#cfcc22570949c98c6689cfcbd2d693d36cdae2e1"
@@ -3378,6 +3492,13 @@ asynckit@^0.4.0:
resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79"
integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==
+available-typed-arrays@^1.0.7:
+ version "1.0.7"
+ resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz#a5cc375d6a03c2efc87a553f3e0b1522def14846"
+ integrity sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==
+ dependencies:
+ possible-typed-array-names "^1.0.0"
+
aws-sdk@^2.1009.0:
version "2.1142.0"
resolved "https://registry.yarnpkg.com/aws-sdk/-/aws-sdk-2.1142.0.tgz#8fca9cfa100153d10d753afbc11a3ad7af6fe72b"
@@ -3478,6 +3599,15 @@ axios@^1.4.0:
form-data "^4.0.0"
proxy-from-env "^1.1.0"
+axios@^1.7.4:
+ version "1.7.8"
+ resolved "https://registry.yarnpkg.com/axios/-/axios-1.7.8.tgz#1997b1496b394c21953e68c14aaa51b7b5de3d6e"
+ integrity sha512-Uu0wb7KNqK2t5K+YQyVCLM76prD5sRFjKHbJYCP1J7JFGEQ6nN7HWn9+04LAeiJ3ji54lgS/gZCH1oxyrf1SPw==
+ dependencies:
+ follow-redirects "^1.15.6"
+ form-data "^4.0.0"
+ proxy-from-env "^1.1.0"
+
babel-runtime@^6.23.0:
version "6.26.0"
resolved "https://registry.yarnpkg.com/babel-runtime/-/babel-runtime-6.26.0.tgz#965c7058668e82b55d7bfe04ff2337bc8b5647fe"
@@ -3773,7 +3903,7 @@ call-bind@^1.0.0:
function-bind "^1.1.1"
get-intrinsic "^1.0.2"
-call-bind@^1.0.7:
+call-bind@^1.0.2, call-bind@^1.0.7:
version "1.0.7"
resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.7.tgz#06016599c40c56498c18769d2730be242b6fa3b9"
integrity sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==
@@ -4391,11 +4521,21 @@ destroy@1.2.0:
resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015"
integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==
+detect-browser@^5.3.0:
+ version "5.3.0"
+ resolved "https://registry.yarnpkg.com/detect-browser/-/detect-browser-5.3.0.tgz#9705ef2bddf46072d0f7265a1fe300e36fe7ceca"
+ integrity sha512-53rsFbGdwMwlF7qvCt0ypLM5V5/Mbl0szB7GPN8y9NCcbknYOeVVXdrXEq+90IwAfrrzt6Hd+u2E2ntakICU8w==
+
detect-libc@^2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.3.tgz#f0cd503b40f9939b894697d19ad50895e30cf700"
integrity sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==
+detect-node@^2.0.4:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.1.0.tgz#c9c70775a49c3d03bc2c06d9a73be550f978f8b1"
+ integrity sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==
+
did-jwt-vc@3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/did-jwt-vc/-/did-jwt-vc-3.1.0.tgz#aa7877c4c1f26ba11883604ac0ece30ca4fe78a4"
@@ -4995,6 +5135,13 @@ follow-redirects@^1.14.4, follow-redirects@^1.14.9, follow-redirects@^1.15.0, fo
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.6.tgz#7f815c0cda4249c74ff09e95ef97c23b5fd0399b"
integrity sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==
+for-each@^0.3.3:
+ version "0.3.3"
+ resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e"
+ integrity sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==
+ dependencies:
+ is-callable "^1.1.3"
+
forever-agent@~0.6.1:
version "0.6.1"
resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91"
@@ -5005,6 +5152,15 @@ form-data-encoder@1.7.2:
resolved "https://registry.yarnpkg.com/form-data-encoder/-/form-data-encoder-1.7.2.tgz#1f1ae3dccf58ed4690b86d87e4f57c654fbab040"
integrity sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==
+form-data@^3.0.0:
+ version "3.0.2"
+ resolved "https://registry.yarnpkg.com/form-data/-/form-data-3.0.2.tgz#83ad9ced7c03feaad97e293d6f6091011e1659c8"
+ integrity sha512-sJe+TQb2vIaIyO783qN6BlMYWMw3WBOHA1Ay2qxsnjuafEOQFJ2JakedOQirT6D5XPRxDvS7AHYyem9fTpb4LQ==
+ dependencies:
+ asynckit "^0.4.0"
+ combined-stream "^1.0.8"
+ mime-types "^2.1.12"
+
form-data@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452"
@@ -5370,6 +5526,13 @@ has-symbols@^1.0.1, has-symbols@^1.0.3:
resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8"
integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==
+has-tostringtag@^1.0.0, has-tostringtag@^1.0.2:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz#2cdc42d40bef2e5b4eeab7c01a73c54ce7ab5abc"
+ integrity sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==
+ dependencies:
+ has-symbols "^1.0.3"
+
has-yarn@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/has-yarn/-/has-yarn-2.1.0.tgz#137e11354a7b5bf11aa5cb649cf0c6f3ff2b2e77"
@@ -5574,6 +5737,14 @@ ipaddr.js@1.9.1:
resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3"
integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==
+is-arguments@^1.0.4:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.1.1.tgz#15b3f88fda01f2a97fec84ca761a560f123efa9b"
+ integrity sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==
+ dependencies:
+ call-bind "^1.0.2"
+ has-tostringtag "^1.0.0"
+
is-binary-path@~2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09"
@@ -5586,6 +5757,11 @@ is-buffer@^2.0.5:
resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-2.0.5.tgz#ebc252e400d22ff8d77fa09888821a24a658c191"
integrity sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==
+is-callable@^1.1.3:
+ version "1.2.7"
+ resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.7.tgz#3bc2a85ea742d9e36205dcacdd72ca1fdc51b055"
+ integrity sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==
+
is-ci@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-2.0.0.tgz#6bc6334181810e04b5c22b3d589fdca55026404c"
@@ -5613,6 +5789,13 @@ is-fullwidth-code-point@^3.0.0:
resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d"
integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==
+is-generator-function@^1.0.7:
+ version "1.0.10"
+ resolved "https://registry.yarnpkg.com/is-generator-function/-/is-generator-function-1.0.10.tgz#f1558baf1ac17e0deea7c0415c438351ff2b3c72"
+ integrity sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==
+ dependencies:
+ has-tostringtag "^1.0.0"
+
is-glob@^4.0.1, is-glob@~4.0.1:
version "4.0.3"
resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084"
@@ -5683,6 +5866,13 @@ is-stream@^2.0.0:
resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077"
integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==
+is-typed-array@^1.1.3:
+ version "1.1.13"
+ resolved "https://registry.yarnpkg.com/is-typed-array/-/is-typed-array-1.1.13.tgz#d6c5ca56df62334959322d7d7dd1cca50debe229"
+ integrity sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==
+ dependencies:
+ which-typed-array "^1.1.14"
+
is-typedarray@^1.0.0, is-typedarray@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a"
@@ -5766,6 +5956,11 @@ json-buffer@3.0.0:
resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.0.tgz#5b1f397afc75d677bde8bcfc0e47e1f9a3d9a898"
integrity sha1-Wx85evx11ne96Lz8Dkfh+aPZqJg=
+json-ptr@^3.1.0:
+ version "3.1.1"
+ resolved "https://registry.yarnpkg.com/json-ptr/-/json-ptr-3.1.1.tgz#184c3d48db659fa9bbc1519f7db6f390ddffb659"
+ integrity sha512-SiSJQ805W1sDUCD1+/t1/1BIrveq2Fe9HJqENxZmMCILmrPI7WhS/pePpIOx85v6/H2z1Vy7AI08GV2TzfXocg==
+
json-schema-ref-parser@^9.0.9:
version "9.0.9"
resolved "https://registry.yarnpkg.com/json-schema-ref-parser/-/json-schema-ref-parser-9.0.9.tgz#66ea538e7450b12af342fa3d5b8458bc1e1e013f"
@@ -6141,11 +6336,21 @@ lodash.defaults@^4.2.0:
resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c"
integrity sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw=
+lodash.defaultsdeep@^4.6.1:
+ version "4.6.1"
+ resolved "https://registry.yarnpkg.com/lodash.defaultsdeep/-/lodash.defaultsdeep-4.6.1.tgz#512e9bd721d272d94e3d3a63653fa17516741ca6"
+ integrity sha512-3j8wdDzYuWO3lM3Reg03MuQR957t287Rpcxp1njpEa8oDrikb+FwGdW3n+FELh/A6qib6yPit0j/pv9G/yeAqA==
+
lodash.difference@^4.5.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/lodash.difference/-/lodash.difference-4.5.0.tgz#9ccb4e505d486b91651345772885a2df27fd017c"
integrity sha1-nMtOUF1Ia5FlE0V3KIWi3yf9AXw=
+lodash.flatmap@^4.5.0:
+ version "4.5.0"
+ resolved "https://registry.yarnpkg.com/lodash.flatmap/-/lodash.flatmap-4.5.0.tgz#ef8cbf408f6e48268663345305c6acc0b778702e"
+ integrity sha512-/OcpcAGWlrZyoHGeHh3cAoa6nGdX6QYtmzNP84Jqol6UEQQ2gIaU3H+0eICcjcKGl0/XF8LWOujNn9lffsnaOg==
+
lodash.flatten@^4.4.0:
version "4.4.0"
resolved "https://registry.yarnpkg.com/lodash.flatten/-/lodash.flatten-4.4.0.tgz#f31c22225a9632d2bbf8e4addbef240aa765a61f"
@@ -6874,6 +7079,15 @@ passport-google-oauth20@^2.0.0:
dependencies:
passport-oauth2 "1.x.x"
+passport-oauth1@1.x.x:
+ version "1.3.0"
+ resolved "https://registry.yarnpkg.com/passport-oauth1/-/passport-oauth1-1.3.0.tgz#5d57f1415c8e28e46b461a12ec1b492934f7c354"
+ integrity sha512-8T/nX4gwKTw0PjxP1xfD0QhrydQNakzeOpZ6M5Uqdgz9/a/Ag62RmJxnZQ4LkbdXGrRehQHIAHNAu11rCP46Sw==
+ dependencies:
+ oauth "0.9.x"
+ passport-strategy "1.x.x"
+ utils-merge "1.x.x"
+
passport-oauth2@1.x.x:
version "1.6.1"
resolved "https://registry.yarnpkg.com/passport-oauth2/-/passport-oauth2-1.6.1.tgz#c5aee8f849ce8bd436c7f81d904a3cd1666f181b"
@@ -6896,6 +7110,23 @@ passport-oauth2@^1.5.0, passport-oauth2@^1.6.1:
uid2 "0.0.x"
utils-merge "1.x.x"
+passport-oauth@1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/passport-oauth/-/passport-oauth-1.0.0.tgz#90aff63387540f02089af28cdad39ea7f80d77df"
+ integrity sha512-4IZNVsZbN1dkBzmEbBqUxDG8oFOIK81jqdksE3HEb/vI3ib3FMjbiZZ6MTtooyYZzmKu0BfovjvT1pdGgIq+4Q==
+ dependencies:
+ passport-oauth1 "1.x.x"
+ passport-oauth2 "1.x.x"
+
+passport-spotify@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/passport-spotify/-/passport-spotify-2.0.0.tgz#0885e21867660fd304aadd26506ef96c048428db"
+ integrity sha512-iY1ZFP3m1iY/o4OehTfMHLfYl8HzCAhWJJXtNPQzCZuvuJnus5buvSAVLaa7UkFMalfmRKcHdVen9XMQ63bVcw==
+ dependencies:
+ passport-oauth "1.0.0"
+ querystring "~0.2.0"
+ util "~0.12.0"
+
passport-strategy@1.x.x:
version "1.0.0"
resolved "https://registry.yarnpkg.com/passport-strategy/-/passport-strategy-1.0.0.tgz#b5539aa8fc225a3d1ad179476ddf236b440f52e4"
@@ -7014,6 +7245,11 @@ pinkie@^2.0.0:
resolved "https://registry.yarnpkg.com/pinkie/-/pinkie-2.0.4.tgz#72556b80cfa0d48a974e80e77248e80ed4f7f870"
integrity sha512-MnUuEycAemtSaeFSjXKW/aroV7akBbY+Sv+RkyqFjgAe73F+MR0TBWKBRDkmfWq/HiFmdavfZ1G7h4SPZXaCSg==
+possible-typed-array-names@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz#89bb63c6fada2c3e90adc4a647beeeb39cc7bf8f"
+ integrity sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==
+
postcss@^8.3.11:
version "8.4.49"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.49.tgz#4ea479048ab059ab3ae61d082190fabfd994fe19"
@@ -7374,6 +7610,11 @@ querystring@0.2.0:
resolved "https://registry.yarnpkg.com/querystring/-/querystring-0.2.0.tgz#b209849203bb25df820da756e747005878521620"
integrity sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA=
+querystring@~0.2.0:
+ version "0.2.1"
+ resolved "https://registry.yarnpkg.com/querystring/-/querystring-0.2.1.tgz#40d77615bb09d16902a85c3e38aa8b5ed761c2dd"
+ integrity sha512-wkvS7mL/JMugcup3/rMitHmd9ecIGd2lhFhK9N3UUQ450h66d1r3Y9nvXzQAW1Lq+wyx61k/1pfKS5KuKiyEbg==
+
querystringify@^2.1.1:
version "2.2.0"
resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.2.0.tgz#3345941b4153cb9d082d8eee4cda2016a9aef7f6"
@@ -7826,6 +8067,17 @@ sparse-bitfield@^3.0.3:
dependencies:
memory-pager "^1.0.2"
+spotify-api-sdk@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/spotify-api-sdk/-/spotify-api-sdk-1.0.0.tgz#83c63455b8ff15c0dd95836870af49d3bc4c9035"
+ integrity sha512-hgZh0YInQatgOMvlak5RqBtdHCVYdJRJXvHio0X30yPo5FOso8/b5H3/7wpb179uuqVncACO20tjAjKF/2giOA==
+ dependencies:
+ "@apimatic/authentication-adapters" "^0.5.4"
+ "@apimatic/axios-client-adapter" "^0.3.4"
+ "@apimatic/core" "^0.10.12"
+ "@apimatic/oauth-adapters" "^0.4.6"
+ "@apimatic/schema" "^0.7.12"
+
sprintf-js@~1.0.2:
version "1.0.3"
resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c"
@@ -8087,6 +8339,11 @@ tiktoken@^1.0.10:
resolved "https://registry.yarnpkg.com/tiktoken/-/tiktoken-1.0.17.tgz#53b9b38b8b1a9c6996cded21f6ef0ff473fae265"
integrity sha512-UuFHqpy/DxOfNiC3otsqbx3oS6jr5uKdQhB/CvDEroZQbVHt+qAK+4JbIooabUWKU9g6PpsFylNu9Wcg4MxSGA==
+tiny-warning@^1.0.3:
+ version "1.0.3"
+ resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754"
+ integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==
+
tlds@1.252.0:
version "1.252.0"
resolved "https://registry.yarnpkg.com/tlds/-/tlds-1.252.0.tgz#71d9617f4ef4cc7347843bee72428e71b8b0f419"
@@ -8234,6 +8491,11 @@ tslib@2.4.0:
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3"
integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==
+tslib@^2.1.0:
+ version "2.8.1"
+ resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"
+ integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
+
tslib@^2.6.2, tslib@^2.6.3:
version "2.8.0"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.0.tgz#d124c86c3c05a40a91e6fdea4021bd31d377971b"
@@ -8475,6 +8737,17 @@ util-deprecate@^1.0.1, util-deprecate@~1.0.1:
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=
+util@~0.12.0:
+ version "0.12.5"
+ resolved "https://registry.yarnpkg.com/util/-/util-0.12.5.tgz#5f17a6059b73db61a875668781a1c2b136bd6fbc"
+ integrity sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==
+ dependencies:
+ inherits "^2.0.3"
+ is-arguments "^1.0.4"
+ is-generator-function "^1.0.7"
+ is-typed-array "^1.1.3"
+ which-typed-array "^1.1.2"
+
utils-merge@1.0.1, utils-merge@1.x.x:
version "1.0.1"
resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
@@ -8565,6 +8838,17 @@ whatwg-url@^5.0.0:
tr46 "~0.0.3"
webidl-conversions "^3.0.0"
+which-typed-array@^1.1.14, which-typed-array@^1.1.2:
+ version "1.1.15"
+ resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.15.tgz#264859e9b11a649b388bfaaf4f767df1f779b38d"
+ integrity sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA==
+ dependencies:
+ available-typed-arrays "^1.0.7"
+ call-bind "^1.0.7"
+ for-each "^0.3.3"
+ gopd "^1.0.1"
+ has-tostringtag "^1.0.2"
+
which@2.0.2, which@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1"