Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified assets/spotify/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,10 @@
"passport": "^0.5.2",
"passport-facebook": "^3.0.0",
"passport-google-oauth20": "^2.0.0",
"passport-spotify": "^2.0.0",
"pdf-parse": "^1.1.1",
"sanitize-html": "^2.13.1",
"spotify-api-sdk": "^1.0.0",
"string-strip-html": "8.5.0",
"tdlib-native": "^3.1.0",
"ts-mocha": "^9.0.2",
Expand Down
2 changes: 1 addition & 1 deletion src/providers/google/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export enum YoutubeActivityType {
}

export interface Person {
email: string
email?: string
displayName?: string
}

Expand Down
28 changes: 28 additions & 0 deletions src/providers/spotify/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Spotify Provider

## Configuration

1. Go to the [Spotify Developer Dashboard](https://developer.spotify.com/dashboard)
2. Log in with your Spotify account
3. Click `Create an App`
4. Fill in the app name and description, and choose `Web API`
5. Once created, click `Settings`
6. Note down your `Client ID` and `Client Secret`
7. Add your redirect URI (typically `http://localhost:5021/callback/spotify` for local development)

## Spotify Favourite(User's Top Tracks)

GET https://api.spotify.com/v1/me/top/tracks


### Query Parameters

| Parameter | Value | Description |
|------------|----------|--------------------------------------------|
| limit | integer | Optional. The maximum number of items to return. Default: 20. Minimum: 1. Maximum: 50. |
| offset | integer | Optional. The index of the first item to return. Default: 0 |
| time_range | string | Optional. Over what time frame the affinities are computed. Valid values:<br>• `short_term` (last 4 weeks)<br>• `medium_term` (last 6 months)<br>• `long_term` (calculated from several years of data and including all new data as it becomes available). Default: medium_term |




171 changes: 171 additions & 0 deletions src/providers/spotify/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { Request, Response } from "express";
import Base from "../BaseProvider";
import { SpotifyProviderConfig } from "./interfaces";
import { ConnectionCallbackResponse, PassportProfile } from "../../interfaces";
import { Client, OAuthScopeEnum, OAuthToken } from "spotify-api-sdk";
import SpotifyFollowing from "./spotify-following";
import SpotifyFavouriteHandler from "./spotify-favourite";
import SpotifyPlayHistory from "./spotify-history";
import SpotifyPlaylistHandler from "./spotify-playlist";

const { Strategy: SpotifyStrategy } = require("passport-spotify");
const passport = require("passport");

export default class SpotifyProvider extends Base {
protected config: SpotifyProviderConfig;

public getProviderName() {
return "spotify";
}

public getProviderLabel() {
return "Spotify";
}

public getProviderApplicationUrl() {
return "https://www.spotify.com/";
}

public syncHandlers(): any[] {
return [
SpotifyFollowing,
SpotifyFavouriteHandler,
SpotifyPlayHistory,
SpotifyPlaylistHandler
];
}

public getScopes(): OAuthScopeEnum[] {
return [
OAuthScopeEnum.PlaylistReadPrivate,
OAuthScopeEnum.UserReadPrivate,
OAuthScopeEnum.UserReadEmail,
OAuthScopeEnum.UserFollowRead,
OAuthScopeEnum.UserTopRead,
OAuthScopeEnum.UserReadRecentlyPlayed
];
}

public async connect(req: Request, res: Response, next: any): Promise<any> {
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<ConnectionCallbackResponse> {
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 = <ConnectionCallbackResponse>await promise;
return result;
}

public async getApi(accessToken?: string, refreshToken?: string): Promise<Client> {

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;
}
}
11 changes: 11 additions & 0 deletions src/providers/spotify/interfaces.ts
Original file line number Diff line number Diff line change
@@ -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
}
138 changes: 138 additions & 0 deletions src/providers/spotify/spotify-favourite.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import CONFIG from "../../config";
import {
SyncProviderLogEvent,
SyncProviderLogLevel,
SyncHandlerPosition,
SyncResponse,
SyncHandlerStatus,
ProviderHandlerOption,
ConnectionOptionType
} from "../../interfaces";
import { SchemaFavourite, SchemaFavouriteContentType, SchemaFavouriteType } from "../../schemas";
import { SpotifyHandlerConfig } from "./interfaces";
import AccessDeniedError from "../AccessDeniedError";
import InvalidTokenError from "../InvalidTokenError";
import BaseSyncHandler from "../BaseSyncHandler";
import { Client, UsersController } from "spotify-api-sdk";

const MAX_BATCH_SIZE = 50;

export default class SpotifyFavouriteHandler extends BaseSyncHandler {

protected config: SpotifyHandlerConfig;

public getLabel(): string {
return "Spotify Favourite Tracks";
}

public getName(): string {
return "spotify-favourite";
}

public getSchemaUri(): string {
return CONFIG.verida.schemas.FAVORITES;
}

public getProviderApplicationUrl(): string {
return "https://spotify.com/";
}

public getOptions(): ProviderHandlerOption[] {
return [{
id: 'backdate',
label: 'Backdate history',
type: ConnectionOptionType.ENUM,
enumOptions: [{
value: '1-month',
label: '1 month'
}, {
value: '3-months',
label: '3 months'
}, {
value: '6-months',
label: '6 months'
}, {
value: '12-months',
label: '12 months'
}],
defaultValue: '3-months'
}];
}

/**
* Don't use pagination and Item range tracking here
*
* @param client
* @param syncPosition
* @returns
*/
public async _sync(
client: Client,
syncPosition: SyncHandlerPosition
): Promise<SyncResponse> {
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 };
}
}
Loading
Loading