From 2216038b251ae3fd74c0acb613bd43eade7ca992 Mon Sep 17 00:00:00 2001 From: chime3 Date: Sun, 1 Dec 2024 15:11:05 -0700 Subject: [PATCH 1/9] feat: added spotify connector and handlers --- assets/spotify/icon.png | Bin 0 -> 3498 bytes package.json | 2 + src/interfaces.ts | 3 +- src/providers/spotify/index.ts | 171 ++++++++++++ src/providers/spotify/interfaces.ts | 11 + src/providers/spotify/spotify-favorite.ts | 138 ++++++++++ src/providers/spotify/spotify-following.ts | 198 ++++++++++++++ src/providers/spotify/spotify-history.ts | 192 ++++++++++++++ src/providers/spotify/spotify-playlist.ts | 172 +++++++++++++ src/schemas.ts | 38 +++ src/serverconfig.example.json | 7 + yarn.lock | 286 ++++++++++++++++++++- 12 files changed, 1216 insertions(+), 2 deletions(-) create mode 100644 assets/spotify/icon.png create mode 100644 src/providers/spotify/index.ts create mode 100644 src/providers/spotify/interfaces.ts create mode 100644 src/providers/spotify/spotify-favorite.ts create mode 100644 src/providers/spotify/spotify-following.ts create mode 100644 src/providers/spotify/spotify-history.ts create mode 100644 src/providers/spotify/spotify-playlist.ts diff --git a/assets/spotify/icon.png b/assets/spotify/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..dbb66d13da6e25927cef3c568b7fc676e40ddbf3 GIT binary patch literal 3498 zcmV;b4OQ}qP)ef4O2-(K~#9!)!j{dsyGw?;CKUjN$b9evPaRqMcgZu z|NmD86s=W4PQK4%GuL_U)OXT+o*V)Zx5Beqaqv-Oi%VS}4u`s~TXy{yj(+m}BXNx8 zZG9-oQz^G~n{{-Y=l5EDi)%w(R~N zM@Hvh@|ArS@zMN;=oPwSA~O23AljvC+dLv#uVp;7kw!#9Gj3AN_sWigW~SBr7)QdR z#iAfqg*L&_;zF#;cVM*kKsQ=;rs(0H?7pmoeLN5+Wy^%#_uh&Ln(;kxRFWnTn&ky? zRwxUCc51#4BA(HX&3Cm|v~%;I_KEgjzO`$gXm{hrpD~2_MEfy!YM*FNW4#P}V{(YFc1GQ@u^Vil8W!7J82Zop23wPLPyF z13kxrPB|EjqZ|INP(kP=8Z%jg(R)1Tgagqlf~ZtL^a&F>;~w5C8vc z6s}`S_RSbtb9Y$Q?fG5%QaXEGx?1Utq6;0c()(2H9lUVT zsNd1hCcQnLg?7o>+S=WWpg~Q#JThTg7MAYKI~vfWX?+%Ur9Bijjrtv}`f=2vlAjv* zr8IBUZ)io6-kyc4vM{Y~_J*Ep(n%{^mc_-0y?Kk8PAoj=w{Ts!2|E?9n}EIFw(NqP zyrQM2L$nc|O0NA3+v|2~72K?Suh4Bx{fwR~{+_3z*G4vf(Xy!}5uS_IMIq@6x`f4DCnK}Vr zq0!1a1Cu8-!Gcz*f45sX9<*w9Y#!(pBD7c)%ClQCb=K0vKu=FI>j(OT3O(7bufqqK zIzHTpNNUCKL8eZ`|7m>{yB5!CdPj3*pkXo|+V)pu_tF2p^v3{)x<0m#yPF-)==V0A ztF||?(dqSq?3ZJLZGAjDS62gisob-_ui6(ESVWF)kF80Qk=|lF&oe)qSHRif}W+e`y?Op$aKQQ>!6pL#XC*hxqQqc(}$jvIf*Pk+KCQ(Fa6~c z#X0`&ne5`=6&{2Ake=)l)k*EQVX)Aiy^^jih%))$XdDE$Wf!txn5d7JliI-{pb2{~ z8yiTZj0wn*$Vs*!B3Z1AY4u35ludXfV&;;njVxu6+?Z%mo(+bND>`a!`ebFVWU~v2 zoFkSr+5>fuj-2*5F^ouO7ZN|QZKu}}aiE{Bg9o9j4viqeRC2;Pmo1mc#J(k<`mC`H zn01?gIl-X*1}~WKpnsRZ%B@Sc;UZ`r$N2{K!z+4Ekg8A8$QVKNl*nJT^qK&Q3jJ68 zK_<}F9ltE=`gm+_rLOC;G|gh86`-OC$*j<2eH_c_6+CM%Mv1Xcv&_VyVn%XfuqrN^ z8D#W67JXBT3ucA}2j@62PWh^yJmhK&(3;^b)$9D;HtLxwEL0KAMIJTD-?T_Db%3Tv z)3c-Y5D9IU3uzm@d`2(Bkgu)8QJ`u=pckKQT^uSp3A}b_*vgc;N}qNSeBBVx$~19! zN^T#2oq^D9vt+Gb>6Q}6a@zHAvt=7eAZYjLdHmuXy^fiwX^QreLBS7R(Y}&5FXm|V zugP2Trs?}uC;;s*RRxr(X@>TgylFOML(pC`nJ)iFw22ym_MDxk|JvdBHEp2AfL7!@ zB1P+}Av0nnkf|XxJyVRe*)4=Ol(s!*`d#-}2lvqFg2TVRP{6DTQooU-R7bW2~U+2ET7M1;stA^W0$3 zDd_q-C10TEnRSGS?f`6Hh;9vw<=p~I+-m0!scl#T*+2u3b-S<*4bh{Us@{;GnG=en zCE(r06$zZ~p$NY2QKA)DgiD-gc_NMKB<=|vA@6FUvO$`LEJ%cM)Xe04*|d^4X?fBn z0zr#NXla}thK_X&n&cD*6fP(6?r!@wtmz)TkA)uXjsSEL6+M!$37N;!4t*ks)Hw{D z%?TnkWE%e$8~v(2!O*d9VBKjC)0>P$zbE@!cj-}e=;#;pR(h=i2l_WG3!^ms^N7*W zk97!a%D6s6>1H0kKF4gB{5WK0zyf2G_h`E*v43W{JwN3YW{dVR^_26dx?4SEz+j#w zx_?qRbCmyBme5~#az&pE!DnUgCA%EluOZVb`b%F7kKppl!7+Mv?TAa=I@Wx8eQ|MG z-J2^~Yf9XjWal}3*tNQ%bB$(UoayF{-P|5_hmx&QAf46raIFCBryYf5;ZC|F9iGq! z4b(2~HeKxP(aG&$chH8*%^ii-d4#u9D+0W=-ql(2!nS4q8_>&8744#BZw54Apq{zR zrLn5)WC#{tSKcrNNMPc+v&{PGiqMsAqXq^Cln(;xs;j3rGH{~P zJ{CG*0mUXwGsV7nevL!{AE~w96hdp}Dr1xKXS90rva{HpPS&s1)=EV z^=(ig@7viKqSLpxL4`cm-L6T%#9~#lO98#0$26$_zbk7(@}Kr46JLK z2Y6?@$;Qy(^MdHHh3`3=Q8cEj(Z)9bW9T_%^h|=r{pxPX>*zUVbS6RLxmi)NmVTfg zkM}~(UgDL_1Nri1_bkVYZlq|u?{=p`iq6mudb0^M+Ci_HKxYN!)*(SheX3zEQ zxBlqX9WTf?G`{s|13+iU_AiM5pc%6L%Lamu{DPOa!a!(TQ+X<2HH~Vhu7GGvTSY3M z=nq`o5QNS+u5JiJJeRvjkjcvZa-<~1E_1ww@dI#x7ctw2lG)5FI&*QF(5TBSh8 z8TJ}Wbc`7$6_)4-eX$?zEYaaJS2n^P9Xd%U5f0Fy`(on+9d=$^3n%C-;FB5mn36p@ zWCLlOp~HK zT=#qRHUY06FZSl6F!)|8v~|O#XORU*Ti#Hb+=h+NXsagjJ#@kFXydo|C5^E8UMqCB zRyDb{>w5iFM06%H{TEX*5gFaF_1eg`G5sQ!=*zNCf*5McZ Y2j9y>$u0P=ivR!s07*qoM6N<$f|rBkqW}N^ literal 0 HcmV?d00001 diff --git a/package.json b/package.json index d057a792..c88dacaa 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,9 @@ "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", + "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/interfaces.ts b/src/interfaces.ts index 0475333e..078973ae 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -224,5 +224,6 @@ export interface SyncItemsResult { export enum SyncItemsBreak { ID = "id", - TIMESTAMP = "timestamp" + TIMESTAMP = "timestamp", + NONE = "NONE" } \ No newline at end of file diff --git a/src/providers/spotify/index.ts b/src/providers/spotify/index.ts new file mode 100644 index 00000000..e6c4a370 --- /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 { access } from "fs"; +import SpotifyFollowing from "./spotify-following"; +import SpotifyFavoriteHandler from "./spotify-favorite"; +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, + SpotifyFavoriteHandler, + SpotifyPlayHistory, + SpotifyPlaylistHandler + ]; + } + + public getScopes(): OAuthScopeEnum[] { + return [ + OAuthScopeEnum.PlaylistReadPrivate, + OAuthScopeEnum.UserReadPrivate, + OAuthScopeEnum.UserReadEmail, + OAuthScopeEnum.UserFollowRead, + OAuthScopeEnum.UserTopRead, + ]; + } + + 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-favorite.ts b/src/providers/spotify/spotify-favorite.ts new file mode 100644 index 00000000..10953a35 --- /dev/null +++ b/src/providers/spotify/spotify-favorite.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 SpotifyFavoriteHandler extends BaseSyncHandler { + + protected config: SpotifyHandlerConfig; + + public getLabel(): string { + return "Spotify Favorite Tracks"; + } + + public getName(): string { + return "spotify-favorites"; + } + + 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..5b6aaa2e --- /dev/null +++ b/src/providers/spotify/spotify-history.ts @@ -0,0 +1,192 @@ +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-play-history"; + } + + public getSchemaUri(): string { + return CONFIG.verida.schemas.PLAY_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[] = []; + + let currentRange = rangeTracker.nextRange(); + let offset = currentRange.startId; + + const response = await playerController.getRecentlyPlayed(this.config.batchSize); + 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); + 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[], + breakId: string + ): Promise<{ items: SchemaHistory[], breakHit?: SyncItemsBreak }> { + const results: SchemaHistory[] = []; + let breakHit: SyncItemsBreak; + + for (const playHistory of items) { + const trackId = playHistory.track.id; + + if (breakId && trackId === breakId) { + const logEvent: SyncProviderLogEvent = { + level: SyncProviderLogLevel.DEBUG, + message: `Break ID hit (${breakId})` + }; + this.emit('log', logEvent); + breakHit = SyncItemsBreak.ID; + break; + } + + const trackName = playHistory.track.name; + const albumName = playHistory.track.album.name; + const artists = playHistory.track.artists.map((artist: any) => artist.name).join(", "); + const uri = playHistory.track.externalUrls?.spotify ?? playHistory.track.uri ?? ''; + const icon = playHistory.track.album.images?.[0]?.url ?? ''; + const playedAt = playHistory.played_at; + + results.push({ + _id: this.buildItemId(trackId), + name: `${trackName} by ${artists}`, + icon: icon, + uri: uri, + sourceId: trackId, + sourceData: playHistory.track, + sourceAccountId: this.provider.getAccountId(), + sourceApplication: this.getProviderApplicationUrl(), + insertedAt: new Date().toISOString(), + timestamp: playedAt, + activityType: SchemaHistoryActivityType.LISTENING + }); + } + + return { items: results, breakHit: breakHit ?? SyncItemsBreak.NONE }; + } +} + diff --git a/src/providers/spotify/spotify-playlist.ts b/src/providers/spotify/spotify-playlist.ts new file mode 100644 index 00000000..a71e5243 --- /dev/null +++ b/src/providers/spotify/spotify-playlist.ts @@ -0,0 +1,172 @@ +import CONFIG from "../../config"; +import { + SyncProviderLogEvent, + SyncProviderLogLevel, + SyncHandlerPosition, + SyncResponse, + SyncHandlerStatus, + ProviderHandlerOption, + ConnectionOptionType +} 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"; + +const MAX_BATCH_SIZE = 50; + +export default class SpotifyPlaylistHandler extends BaseSyncHandler { + + protected config: SpotifyHandlerConfig; + + public getLabel(): string { + return "Spotify Playlists"; + } + + public getName(): string { + return "spotify-playlists"; + } + + 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; + 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.limit; + + 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 description = playlist.description || ''; + const collaborative = playlist.collaborative; + const externalUrl = playlist.external_urls?.spotify ?? ''; + const href = playlist.href ?? ''; + const icon = playlist.images?.[0]?.url ?? ''; + + const tracks: SchemaSpotifyTrack[] = playlist.tracks.items.map((track: any) => { + return { + id: track.track.id, + title: track.track.name, + artist: track.track.artists.map((artist: any) => artist.name).join(", "), + album: track.track.album.name, + thumbnail: track.track.album.images?.[0]?.url ?? '', + url: track.track.external_urls?.spotify ?? '', + type: SchemaPlaylistType.AUDIO, // Default to audio, or update if needed for videos + }; + }); + + results.push({ + _id: this.buildItemId(playlistId), + name: playlistName, + icon: icon, + uri: externalUrl, + type: SchemaPlaylistType.AUDIO, + tracks: tracks, + owner: undefined + }); + } + + return { items: results }; + } +} 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 d25710f7..56f334cc 100644 --- a/src/serverconfig.example.json +++ b/src/serverconfig.example.json @@ -114,6 +114,13 @@ "messagesPerGroupLimit": 100, "maxGroupSize": 50, "useDbPos": true + }, + "spotify": { + "label": "Spotify", + "clientId": "", + "clientSecret": "", + "batchSize": 50, + "maxSyncLoops": 1 } }, "providerDefaults": { diff --git a/yarn.lock b/yarn.lock index 90a72609..b03c5456 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17,6 +17,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" + "@discordjs/builders@^1.9.0": version "1.9.0" resolved "https://registry.yarnpkg.com/@discordjs/builders/-/builders-1.9.0.tgz#71fa6de91132bd1deaff2a9daea7aa5d5c9f124a" @@ -1690,6 +1804,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" @@ -1781,6 +1902,15 @@ axios@^1.3.3: 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" @@ -2034,7 +2164,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== @@ -2598,11 +2728,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" @@ -3165,6 +3305,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" @@ -3175,6 +3322,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" @@ -3533,6 +3689,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" @@ -3729,6 +3892,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" @@ -3736,6 +3907,11 @@ is-binary-path@~2.1.0: dependencies: binary-extensions "^2.0.0" +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" @@ -3763,6 +3939,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" @@ -3828,6 +4011,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" @@ -3904,6 +4094,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" @@ -4190,11 +4385,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" @@ -4763,6 +4968,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" @@ -4785,6 +4999,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" @@ -4898,6 +5129,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== + pouchdb-abstract-mapreduce@7.3.1: version "7.3.1" resolved "https://registry.yarnpkg.com/pouchdb-abstract-mapreduce/-/pouchdb-abstract-mapreduce-7.3.1.tgz#96ff4a0f41cbe273f3f52fde003b719005a2093c" @@ -5143,6 +5379,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" @@ -5546,6 +5787,17 @@ spark-md5@3.0.2: resolved "https://registry.yarnpkg.com/spark-md5/-/spark-md5-3.0.2.tgz#7952c4a30784347abcee73268e473b9c0167e3fc" integrity sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw== +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" @@ -5780,6 +6032,11 @@ through@^2.3.8: resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" integrity sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg== +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" @@ -5904,6 +6161,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" @@ -6135,6 +6397,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" @@ -6207,6 +6480,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" From 911a0ba6adf7919dbfd0475a69ada0cd5a443af2 Mon Sep 17 00:00:00 2001 From: chime3 Date: Sun, 1 Dec 2024 18:41:32 -0700 Subject: [PATCH 2/9] fix: revert sync item break --- src/interfaces.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/interfaces.ts b/src/interfaces.ts index 078973ae..0475333e 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -224,6 +224,5 @@ export interface SyncItemsResult { export enum SyncItemsBreak { ID = "id", - TIMESTAMP = "timestamp", - NONE = "NONE" + TIMESTAMP = "timestamp" } \ No newline at end of file From a15c4f6f181d3ec081e9d712035d47c9139e53bb Mon Sep 17 00:00:00 2001 From: chime3 Date: Tue, 10 Dec 2024 16:35:38 -0700 Subject: [PATCH 3/9] fix: breakHit field --- src/providers/spotify/spotify-history.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/providers/spotify/spotify-history.ts b/src/providers/spotify/spotify-history.ts index 5b6aaa2e..5d8dc696 100644 --- a/src/providers/spotify/spotify-history.ts +++ b/src/providers/spotify/spotify-history.ts @@ -186,7 +186,7 @@ export default class SpotifyPlayHistory extends BaseSyncHandler { }); } - return { items: results, breakHit: breakHit ?? SyncItemsBreak.NONE }; + return { items: results, breakHit}; } } From 64e1c68260d2eb0df3609268d837b0808caad5c4 Mon Sep 17 00:00:00 2001 From: chime3 Date: Wed, 15 Jan 2025 23:28:36 -0700 Subject: [PATCH 4/9] feat: spotify following unit test --- tests/providers/spotify/following.tests.ts | 54 ++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 tests/providers/spotify/following.tests.ts diff --git a/tests/providers/spotify/following.tests.ts b/tests/providers/spotify/following.tests.ts new file mode 100644 index 00000000..9ce34238 --- /dev/null +++ b/tests/providers/spotify/following.tests.ts @@ -0,0 +1,54 @@ +const assert = require("assert"); +import { + BaseProviderConfig, + Connection, + SyncHandlerStatus, + SyncHandlerPosition, +} from "../../../src/interfaces"; +import Providers from "../../../src/providers"; +import CommonUtils, { NetworkInstance } from "../../common.utils"; + +import Following from "../../../src/providers/spotify/following"; +import BaseProvider from "../../../src/providers/BaseProvider"; +import { CommonTests, GenericTestConfig } from "../../common.tests"; +import { SchemaFollowing } from "../../../src/schemas"; + +const providerName = "spotify"; +let network: NetworkInstance; +let connection: Connection; +let provider: BaseProvider; +let handlerName = "following"; +let testConfig: GenericTestConfig; +let providerConfig: Omit = {}; + +describe(`${providerName} Following Tests`, function () { + this.timeout(100000); + + this.beforeAll(async function () { + network = await CommonUtils.getNetwork(); + connection = await CommonUtils.getConnection(providerName); + provider = Providers(providerName, network.context, connection); + + testConfig = { + idPrefix: `${provider.getProviderName()}-${connection.profile.id}`, + batchSizeLimitAttribute: "batchSize", + }; + }); + + describe(`Fetch ${providerName} data`, () => { + it(`Can pass basic tests: ${handlerName}`, async () => { + await CommonTests.runGenericTests( + providerName, + Following, + testConfig, + providerConfig, + connection + ); + }); + }); + + this.afterAll(async function () { + const { context } = await CommonUtils.getNetwork(); + await context.close(); + }); +}); \ No newline at end of file From e319ec6c87b0920134c60e2d7a37c217e153510b Mon Sep 17 00:00:00 2001 From: chime3 Date: Sun, 19 Jan 2025 22:50:36 -0700 Subject: [PATCH 5/9] fix: updated spotify config --- src/serverconfig.example.json | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/serverconfig.example.json b/src/serverconfig.example.json index f190c0f5..609648d8 100644 --- a/src/serverconfig.example.json +++ b/src/serverconfig.example.json @@ -111,9 +111,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", @@ -150,13 +154,6 @@ "messagesPerGroupLimit": 100, "maxGroupSize": 50, "useDbPos": true - }, - "spotify": { - "label": "Spotify", - "clientId": "", - "clientSecret": "", - "batchSize": 50, - "maxSyncLoops": 1 } }, "providerDefaults": { From a473d7e8677dc4880cfd51f488d9e67ec0a4bfaa Mon Sep 17 00:00:00 2001 From: chime3 Date: Mon, 20 Jan 2025 20:13:27 -0700 Subject: [PATCH 6/9] fix: removed unused var --- src/providers/spotify/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/providers/spotify/index.ts b/src/providers/spotify/index.ts index e6c4a370..8b231c16 100644 --- a/src/providers/spotify/index.ts +++ b/src/providers/spotify/index.ts @@ -3,7 +3,6 @@ import Base from "../BaseProvider"; import { SpotifyProviderConfig } from "./interfaces"; import { ConnectionCallbackResponse, PassportProfile } from "../../interfaces"; import { Client, OAuthScopeEnum, OAuthToken } from "spotify-api-sdk"; -import { access } from "fs"; import SpotifyFollowing from "./spotify-following"; import SpotifyFavoriteHandler from "./spotify-favorite"; import SpotifyPlayHistory from "./spotify-history"; From 68f688de94562b2dedc7884d21419f06cea2ed8d Mon Sep 17 00:00:00 2001 From: chime3 Date: Mon, 20 Jan 2025 20:13:46 -0700 Subject: [PATCH 7/9] feat: added Spotify readme --- src/providers/spotify/README.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 src/providers/spotify/README.md diff --git a/src/providers/spotify/README.md b/src/providers/spotify/README.md new file mode 100644 index 00000000..cff782da --- /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 Favorite(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 | + + + + From f295b6e25de493c78e2f24bd43b1df32001be18b Mon Sep 17 00:00:00 2001 From: chime3 Date: Wed, 22 Jan 2025 22:30:43 -0700 Subject: [PATCH 8/9] feat: refactored spotify playlist handler --- src/providers/google/interfaces.ts | 2 +- src/providers/spotify/spotify-playlist.ts | 63 +++++++++++++---------- src/serverconfig.example.json | 4 +- src/web/developer/data/data.js | 4 +- 4 files changed, 44 insertions(+), 29 deletions(-) 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/spotify-playlist.ts b/src/providers/spotify/spotify-playlist.ts index a71e5243..12652105 100644 --- a/src/providers/spotify/spotify-playlist.ts +++ b/src/providers/spotify/spotify-playlist.ts @@ -1,12 +1,9 @@ import CONFIG from "../../config"; import { - SyncProviderLogEvent, - SyncProviderLogLevel, SyncHandlerPosition, SyncResponse, SyncHandlerStatus, - ProviderHandlerOption, - ConnectionOptionType + ProviderHandlerOption } from "../../interfaces"; import { SchemaPlaylist, SchemaPlaylistType, SchemaSpotifyTrack } from "../../schemas"; import { SpotifyHandlerConfig } from "./interfaces"; @@ -15,8 +12,7 @@ import InvalidTokenError from "../InvalidTokenError"; import BaseSyncHandler from "../BaseSyncHandler"; import { Client, PlaylistsController } from "spotify-api-sdk"; import { ItemsRangeTracker } from "../../helpers/itemsRangeTracker"; - -const MAX_BATCH_SIZE = 50; +import { Person } from "../google/interfaces"; export default class SpotifyPlaylistHandler extends BaseSyncHandler { @@ -50,18 +46,19 @@ export default class SpotifyPlaylistHandler extends BaseSyncHandler { 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; + 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.limit; + let nextOffset = response.result.offset + response.result.total; if (items.length) { rangeTracker.completedRange({ @@ -138,35 +135,49 @@ export default class SpotifyPlaylistHandler extends BaseSyncHandler { for (const playlist of items) { const playlistId = playlist.id; const playlistName = playlist.name; - const description = playlist.description || ''; - const collaborative = playlist.collaborative; const externalUrl = playlist.external_urls?.spotify ?? ''; - const href = playlist.href ?? ''; const icon = playlist.images?.[0]?.url ?? ''; - - const tracks: SchemaSpotifyTrack[] = playlist.tracks.items.map((track: any) => { - return { - id: track.track.id, - title: track.track.name, - artist: track.track.artists.map((artist: any) => artist.name).join(", "), - album: track.track.album.name, - thumbnail: track.track.album.images?.[0]?.url ?? '', - url: track.track.external_urls?.spotify ?? '', - type: SchemaPlaylistType.AUDIO, // Default to audio, or update if needed for videos - }; - }); + 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, + uri: 'externalUrl', type: SchemaPlaylistType.AUDIO, tracks: tracks, - owner: undefined + 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/serverconfig.example.json b/src/serverconfig.example.json index 609648d8..5932842c 100644 --- a/src/serverconfig.example.json +++ b/src/serverconfig.example.json @@ -32,7 +32,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": "", 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 From 2fe69ad633ea68245fb514767760deea67e8cf52 Mon Sep 17 00:00:00 2001 From: chime3 Date: Sun, 26 Jan 2025 15:05:18 -0700 Subject: [PATCH 9/9] feat: spotify unit test and refactoring --- src/providers/spotify/README.md | 2 +- src/providers/spotify/index.ts | 5 +- ...otify-favorite.ts => spotify-favourite.ts} | 6 +- src/providers/spotify/spotify-history.ts | 45 ++-- src/providers/spotify/spotify-playlist.ts | 4 +- tests/providers/spotify/following.tests.ts | 54 ---- .../spotify/spotify-favourite.tests.ts | 253 ++++++++++++++++++ .../spotify/spotify-following.tests.ts | 174 ++++++++++++ .../spotify/spotify-history.tests.ts | 155 +++++++++++ .../spotify/spotify-playlist.tests.ts | 222 +++++++++++++++ 10 files changed, 840 insertions(+), 80 deletions(-) rename src/providers/spotify/{spotify-favorite.ts => spotify-favourite.ts} (96%) delete mode 100644 tests/providers/spotify/following.tests.ts create mode 100644 tests/providers/spotify/spotify-favourite.tests.ts create mode 100644 tests/providers/spotify/spotify-following.tests.ts create mode 100644 tests/providers/spotify/spotify-history.tests.ts create mode 100644 tests/providers/spotify/spotify-playlist.tests.ts diff --git a/src/providers/spotify/README.md b/src/providers/spotify/README.md index cff782da..1a143573 100644 --- a/src/providers/spotify/README.md +++ b/src/providers/spotify/README.md @@ -10,7 +10,7 @@ 6. Note down your `Client ID` and `Client Secret` 7. Add your redirect URI (typically `http://localhost:5021/callback/spotify` for local development) -## Spotify Favorite(User's Top Tracks) +## Spotify Favourite(User's Top Tracks) GET https://api.spotify.com/v1/me/top/tracks diff --git a/src/providers/spotify/index.ts b/src/providers/spotify/index.ts index 8b231c16..2c31de30 100644 --- a/src/providers/spotify/index.ts +++ b/src/providers/spotify/index.ts @@ -4,7 +4,7 @@ import { SpotifyProviderConfig } from "./interfaces"; import { ConnectionCallbackResponse, PassportProfile } from "../../interfaces"; import { Client, OAuthScopeEnum, OAuthToken } from "spotify-api-sdk"; import SpotifyFollowing from "./spotify-following"; -import SpotifyFavoriteHandler from "./spotify-favorite"; +import SpotifyFavouriteHandler from "./spotify-favourite"; import SpotifyPlayHistory from "./spotify-history"; import SpotifyPlaylistHandler from "./spotify-playlist"; @@ -29,7 +29,7 @@ export default class SpotifyProvider extends Base { public syncHandlers(): any[] { return [ SpotifyFollowing, - SpotifyFavoriteHandler, + SpotifyFavouriteHandler, SpotifyPlayHistory, SpotifyPlaylistHandler ]; @@ -42,6 +42,7 @@ export default class SpotifyProvider extends Base { OAuthScopeEnum.UserReadEmail, OAuthScopeEnum.UserFollowRead, OAuthScopeEnum.UserTopRead, + OAuthScopeEnum.UserReadRecentlyPlayed ]; } diff --git a/src/providers/spotify/spotify-favorite.ts b/src/providers/spotify/spotify-favourite.ts similarity index 96% rename from src/providers/spotify/spotify-favorite.ts rename to src/providers/spotify/spotify-favourite.ts index 10953a35..c824c189 100644 --- a/src/providers/spotify/spotify-favorite.ts +++ b/src/providers/spotify/spotify-favourite.ts @@ -17,16 +17,16 @@ import { Client, UsersController } from "spotify-api-sdk"; const MAX_BATCH_SIZE = 50; -export default class SpotifyFavoriteHandler extends BaseSyncHandler { +export default class SpotifyFavouriteHandler extends BaseSyncHandler { protected config: SpotifyHandlerConfig; public getLabel(): string { - return "Spotify Favorite Tracks"; + return "Spotify Favourite Tracks"; } public getName(): string { - return "spotify-favorites"; + return "spotify-favourite"; } public getSchemaUri(): string { diff --git a/src/providers/spotify/spotify-history.ts b/src/providers/spotify/spotify-history.ts index 5d8dc696..9359fda2 100644 --- a/src/providers/spotify/spotify-history.ts +++ b/src/providers/spotify/spotify-history.ts @@ -28,11 +28,11 @@ export default class SpotifyPlayHistory extends BaseSyncHandler { } public getName(): string { - return "spotify-play-history"; + return "spotify-history"; } public getSchemaUri(): string { - return CONFIG.verida.schemas.PLAY_HISTORY; + return CONFIG.verida.schemas.HISTORY; } public getProviderApplicationUrl(): string { @@ -68,10 +68,12 @@ export default class SpotifyPlayHistory extends BaseSyncHandler { const rangeTracker = new ItemsRangeTracker(syncPosition.thisRef); let items: SchemaHistory[] = []; + // Timestamp based let currentRange = rangeTracker.nextRange(); - let offset = currentRange.startId; + let offset = currentRange.startId ?? 0; + + const response = await playerController.getRecentlyPlayed(this.config.batchSize, BigInt(offset)); - const response = await playerController.getRecentlyPlayed(this.config.batchSize); const result = await this.buildResults(response.result.items, currentRange.endId); items = result.items; @@ -94,7 +96,7 @@ export default class SpotifyPlayHistory extends BaseSyncHandler { const backfillOffset = currentRange.startId; const backfillBatchSize = this.config.batchSize - items.length; - const backfillResponse = await playerController.getRecentlyPlayed(backfillBatchSize); + const backfillResponse = await playerController.getRecentlyPlayed(backfillBatchSize, BigInt(backfillOffset)); const backfillResult = await this.buildResults(backfillResponse.result.items, currentRange.endId); items = items.concat(backfillResult.items); @@ -146,47 +148,54 @@ export default class SpotifyPlayHistory extends BaseSyncHandler { protected async buildResults( items: any[], - breakId: string + 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; + } - if (breakId && trackId === breakId) { + const playedAt = playHistory.playedAt + + if (breakTimeStamp && playedAt <= new Date(breakTimeStamp).toISOString()) { const logEvent: SyncProviderLogEvent = { level: SyncProviderLogLevel.DEBUG, - message: `Break ID hit (${breakId})` + message: `Break timestamp hit (${new Date(breakTimeStamp).toISOString()})` }; this.emit('log', logEvent); - breakHit = SyncItemsBreak.ID; + breakHit = SyncItemsBreak.TIMESTAMP; break; } - const trackName = playHistory.track.name; - const albumName = playHistory.track.album.name; - const artists = playHistory.track.artists.map((artist: any) => artist.name).join(", "); - const uri = playHistory.track.externalUrls?.spotify ?? playHistory.track.uri ?? ''; - const icon = playHistory.track.album.images?.[0]?.url ?? ''; - const playedAt = playHistory.played_at; - + 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, - uri: uri, + 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}; + return { items: results, breakHit }; } } diff --git a/src/providers/spotify/spotify-playlist.ts b/src/providers/spotify/spotify-playlist.ts index 12652105..96845a9e 100644 --- a/src/providers/spotify/spotify-playlist.ts +++ b/src/providers/spotify/spotify-playlist.ts @@ -19,11 +19,11 @@ export default class SpotifyPlaylistHandler extends BaseSyncHandler { protected config: SpotifyHandlerConfig; public getLabel(): string { - return "Spotify Playlists"; + return "Spotify Playlist"; } public getName(): string { - return "spotify-playlists"; + return "spotify-playlist"; } public getSchemaUri(): string { diff --git a/tests/providers/spotify/following.tests.ts b/tests/providers/spotify/following.tests.ts deleted file mode 100644 index 9ce34238..00000000 --- a/tests/providers/spotify/following.tests.ts +++ /dev/null @@ -1,54 +0,0 @@ -const assert = require("assert"); -import { - BaseProviderConfig, - Connection, - SyncHandlerStatus, - SyncHandlerPosition, -} from "../../../src/interfaces"; -import Providers from "../../../src/providers"; -import CommonUtils, { NetworkInstance } from "../../common.utils"; - -import Following from "../../../src/providers/spotify/following"; -import BaseProvider from "../../../src/providers/BaseProvider"; -import { CommonTests, GenericTestConfig } from "../../common.tests"; -import { SchemaFollowing } from "../../../src/schemas"; - -const providerName = "spotify"; -let network: NetworkInstance; -let connection: Connection; -let provider: BaseProvider; -let handlerName = "following"; -let testConfig: GenericTestConfig; -let providerConfig: Omit = {}; - -describe(`${providerName} Following Tests`, function () { - this.timeout(100000); - - this.beforeAll(async function () { - network = await CommonUtils.getNetwork(); - connection = await CommonUtils.getConnection(providerName); - provider = Providers(providerName, network.context, connection); - - testConfig = { - idPrefix: `${provider.getProviderName()}-${connection.profile.id}`, - batchSizeLimitAttribute: "batchSize", - }; - }); - - describe(`Fetch ${providerName} data`, () => { - it(`Can pass basic tests: ${handlerName}`, async () => { - await CommonTests.runGenericTests( - providerName, - Following, - testConfig, - providerConfig, - connection - ); - }); - }); - - 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-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