itpdp/api/src/workflows/sync.ts
2026-04-21 22:01:53 +02:00

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");