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; topTracksByTimeline: Record; 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 { 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, ) { 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, ) { 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> { const sdk = await this.createSdk(userId); const topArtistsByTimeline = {} as Record; 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> { const sdk = await this.createSdk(userId); const topTracksByTimeline = {} as Record; 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 { 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 { 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 { 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 { 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");