247 lines
6.9 KiB
TypeScript
247 lines
6.9 KiB
TypeScript
import { ConfiguredInstance, DBOS } from "@dbos-inc/dbos-sdk";
|
|
import type {
|
|
Artist,
|
|
PlayHistory,
|
|
SavedAlbum,
|
|
SavedTrack,
|
|
Track,
|
|
} from "@spotify/web-api-ts-sdk";
|
|
import { SpotifyApi } from "@spotify/web-api-ts-sdk";
|
|
import { eq } from "drizzle-orm";
|
|
import { auth, SPOTIFY_CLIENT_ID } from "../auth";
|
|
import { db } from "../db";
|
|
import {
|
|
followedArtist,
|
|
savedAlbum,
|
|
savedTrack,
|
|
topArtist,
|
|
topTrack,
|
|
} from "../db/schema";
|
|
import {
|
|
upsertFollowedArtists,
|
|
upsertPlaybackHistory,
|
|
upsertSavedAlbums,
|
|
upsertSavedTracks,
|
|
upsertTopArtists,
|
|
upsertTopTracks,
|
|
} from "../db/spotify";
|
|
|
|
const timelines = ["short_term", "medium_term", "long_term"] as const;
|
|
type Timeline = (typeof timelines)[number];
|
|
|
|
type SyncPayload = {
|
|
topArtistsByTimeline: Record<Timeline, Artist[]>;
|
|
topTracksByTimeline: Record<Timeline, Track[]>;
|
|
followedArtists: Artist[];
|
|
savedAlbums: SavedAlbum[];
|
|
savedTracks: SavedTrack[];
|
|
recentlyPlayed: PlayHistory[];
|
|
};
|
|
|
|
export class SpotifySyncWorkflow extends ConfiguredInstance {
|
|
@DBOS.workflow()
|
|
async syncUser(userId: string) {
|
|
console.log("Sync start");
|
|
const data = await this.fetchSpotifyData(userId);
|
|
console.log("Sync data fetched");
|
|
await this.persistSpotifyData(userId, data);
|
|
console.log("Synced");
|
|
return { ok: true };
|
|
}
|
|
|
|
private async fetchSpotifyData(userId: string): Promise<SyncPayload> {
|
|
const topArtistsByTimeline = await this.fetchTopArtists(userId);
|
|
const topTracksByTimeline = await this.fetchTopTracks(userId);
|
|
const followedArtists = await this.fetchFollowedArtists(userId);
|
|
const savedAlbums = await this.fetchSavedAlbums(userId);
|
|
const savedTracks = await this.fetchSavedTracks(userId);
|
|
const recentlyPlayed = await this.fetchRecentlyPlayed(userId);
|
|
return {
|
|
topArtistsByTimeline,
|
|
topTracksByTimeline,
|
|
followedArtists,
|
|
savedAlbums,
|
|
savedTracks,
|
|
recentlyPlayed,
|
|
};
|
|
}
|
|
|
|
private async persistSpotifyData(userId: string, data: SyncPayload) {
|
|
await this.persistTopArtists(userId, data.topArtistsByTimeline);
|
|
await this.persistTopTracks(userId, data.topTracksByTimeline);
|
|
await this.persistFollowedArtists(userId, data.followedArtists);
|
|
await this.persistSavedAlbums(userId, data.savedAlbums);
|
|
await this.persistSavedTracks(userId, data.savedTracks);
|
|
await this.persistPlaybackHistory(userId, data.recentlyPlayed);
|
|
}
|
|
|
|
@DBOS.step()
|
|
private async persistTopArtists(
|
|
userId: string,
|
|
topArtistsByTimeline: Record<Timeline, Artist[]>,
|
|
) {
|
|
await db.transaction(async (tx) => {
|
|
await tx.delete(topArtist).where(eq(topArtist.userId, userId));
|
|
for (const timeline of timelines) {
|
|
await upsertTopArtists(
|
|
userId,
|
|
timeline,
|
|
topArtistsByTimeline[timeline],
|
|
tx,
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
@DBOS.step()
|
|
private async persistTopTracks(
|
|
userId: string,
|
|
topTracksByTimeline: Record<Timeline, Track[]>,
|
|
) {
|
|
await db.transaction(async (tx) => {
|
|
await tx.delete(topTrack).where(eq(topTrack.userId, userId));
|
|
for (const timeline of timelines) {
|
|
await upsertTopTracks(
|
|
userId,
|
|
timeline,
|
|
topTracksByTimeline[timeline],
|
|
tx,
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
@DBOS.step()
|
|
private async persistFollowedArtists(userId: string, artists: Artist[]) {
|
|
await db.transaction(async (tx) => {
|
|
await tx.delete(followedArtist).where(eq(followedArtist.userId, userId));
|
|
await upsertFollowedArtists(userId, artists, tx);
|
|
});
|
|
}
|
|
|
|
@DBOS.step()
|
|
private async persistSavedAlbums(userId: string, albums: SavedAlbum[]) {
|
|
await db.transaction(async (tx) => {
|
|
await tx.delete(savedAlbum).where(eq(savedAlbum.userId, userId));
|
|
await upsertSavedAlbums(userId, albums, tx);
|
|
});
|
|
}
|
|
|
|
@DBOS.step()
|
|
private async persistSavedTracks(userId: string, tracks: SavedTrack[]) {
|
|
await db.transaction(async (tx) => {
|
|
await tx.delete(savedTrack).where(eq(savedTrack.userId, userId));
|
|
await upsertSavedTracks(userId, tracks, tx);
|
|
});
|
|
}
|
|
|
|
@DBOS.step()
|
|
private async persistPlaybackHistory(userId: string, items: PlayHistory[]) {
|
|
await db.transaction(async (tx) => {
|
|
await upsertPlaybackHistory(userId, items, tx);
|
|
});
|
|
}
|
|
|
|
@DBOS.step()
|
|
private async fetchTopArtists(
|
|
userId: string,
|
|
): Promise<Record<Timeline, Artist[]>> {
|
|
const sdk = await this.createSdk(userId);
|
|
const topArtistsByTimeline = {} as Record<Timeline, Artist[]>;
|
|
for (const timeline of timelines) {
|
|
const topArtists = await sdk.currentUser.topItems(
|
|
"artists",
|
|
timeline,
|
|
50,
|
|
);
|
|
topArtistsByTimeline[timeline] = topArtists.items;
|
|
}
|
|
return topArtistsByTimeline;
|
|
}
|
|
|
|
@DBOS.step()
|
|
private async fetchTopTracks(
|
|
userId: string,
|
|
): Promise<Record<Timeline, Track[]>> {
|
|
const sdk = await this.createSdk(userId);
|
|
const topTracksByTimeline = {} as Record<Timeline, Track[]>;
|
|
for (const timeline of timelines) {
|
|
const topTracks = await sdk.currentUser.topItems("tracks", timeline, 50);
|
|
topTracksByTimeline[timeline] = topTracks.items;
|
|
}
|
|
return topTracksByTimeline;
|
|
}
|
|
|
|
@DBOS.step()
|
|
private async fetchFollowedArtists(userId: string): Promise<Artist[]> {
|
|
const sdk = await this.createSdk(userId);
|
|
const followed: Artist[] = [];
|
|
let after: string | undefined;
|
|
while (true) {
|
|
const page = await sdk.currentUser.followedArtists(after, 50);
|
|
const artists = page.artists;
|
|
followed.push(...artists.items);
|
|
if (!artists.next || artists.items.length === 0) break;
|
|
const lastArtist = artists.items.at(-1);
|
|
after = lastArtist?.id;
|
|
}
|
|
return followed;
|
|
}
|
|
|
|
@DBOS.step()
|
|
private async fetchSavedAlbums(userId: string): Promise<SavedAlbum[]> {
|
|
const sdk = await this.createSdk(userId);
|
|
const saved: SavedAlbum[] = [];
|
|
let offset = 0;
|
|
while (true) {
|
|
const page = await sdk.currentUser.albums.savedAlbums(50, offset);
|
|
saved.push(...page.items);
|
|
offset += page.items.length;
|
|
if (!page.next || offset >= page.total) break;
|
|
}
|
|
return saved;
|
|
}
|
|
|
|
@DBOS.step()
|
|
private async fetchSavedTracks(userId: string): Promise<SavedTrack[]> {
|
|
const sdk = await this.createSdk(userId);
|
|
const saved: SavedTrack[] = [];
|
|
let offset = 0;
|
|
while (true) {
|
|
const page = await sdk.currentUser.tracks.savedTracks(50, offset);
|
|
saved.push(...page.items);
|
|
offset += page.items.length;
|
|
if (!page.next || offset >= page.total) break;
|
|
}
|
|
return saved;
|
|
}
|
|
|
|
@DBOS.step()
|
|
private async fetchRecentlyPlayed(userId: string): Promise<PlayHistory[]> {
|
|
const sdk = await this.createSdk(userId);
|
|
const recentlyPlayed = await sdk.player.getRecentlyPlayedTracks(50);
|
|
return recentlyPlayed.items;
|
|
}
|
|
|
|
private async createSdk(userId: string) {
|
|
const accessToken = await auth.api.getAccessToken({
|
|
body: {
|
|
userId,
|
|
providerId: "spotify",
|
|
},
|
|
});
|
|
return SpotifyApi.withAccessToken(SPOTIFY_CLIENT_ID, {
|
|
access_token: accessToken.accessToken,
|
|
expires_in: accessToken.accessTokenExpiresAt
|
|
? Date.now() - Number(accessToken.accessTokenExpiresAt)
|
|
: 0,
|
|
expires: accessToken.accessTokenExpiresAt
|
|
? Number(accessToken.accessTokenExpiresAt)
|
|
: 0,
|
|
refresh_token: "",
|
|
token_type: "",
|
|
});
|
|
}
|
|
}
|
|
|
|
export const spotifySyncWorkflow = new SpotifySyncWorkflow("spotify-sync");
|