progress on sync
This commit is contained in:
parent
ba27e07749
commit
d4f9a8c2dd
13 changed files with 553 additions and 250 deletions
|
|
@ -7,4 +7,5 @@ export default defineConfig({
|
|||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL!,
|
||||
},
|
||||
schemaFilter: ["public"],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
"typescript": "^5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dbos-inc/dbos-sdk": "^4.14.6",
|
||||
"@spotify/web-api-ts-sdk": "^1.2.0",
|
||||
"@statsfm/statsfm.js": "github.com:statsfm/statsfm.js",
|
||||
"better-auth": "^1.6.5",
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import {
|
|||
import { user } from "./auth-schema";
|
||||
export * from "./auth-schema";
|
||||
|
||||
const platform = pgEnum("platform", ["spotify"]);
|
||||
export const platform = pgEnum("enum_platform", ["spotify", "apple"]);
|
||||
|
||||
export const artist = pgTable("artist", {
|
||||
id: uuid().defaultRandom().primaryKey().notNull(),
|
||||
|
|
@ -34,16 +34,16 @@ export const genre = pgTable("genre", {
|
|||
export const artistGenre = pgTable(
|
||||
"artist_genre",
|
||||
{
|
||||
artist: uuid()
|
||||
artistId: uuid()
|
||||
.references(() => artist.id)
|
||||
.notNull(),
|
||||
genre: uuid()
|
||||
genreId: uuid()
|
||||
.references(() => genre.id)
|
||||
.notNull(),
|
||||
},
|
||||
(artistGenres) => [
|
||||
primaryKey({
|
||||
columns: [artistGenres.artist, artistGenres.genre],
|
||||
columns: [artistGenres.artistId, artistGenres.genreId],
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
|
@ -66,16 +66,16 @@ export const platformImage = pgTable(
|
|||
export const artistImage = pgTable(
|
||||
"artist_image",
|
||||
{
|
||||
artist: uuid()
|
||||
artistId: uuid()
|
||||
.references(() => artist.id)
|
||||
.notNull(),
|
||||
image: uuid()
|
||||
imageId: uuid()
|
||||
.references(() => platformImage.id)
|
||||
.notNull(),
|
||||
},
|
||||
(artistImages) => [
|
||||
primaryKey({
|
||||
columns: [artistImages.artist, artistImages.image],
|
||||
columns: [artistImages.artistId, artistImages.imageId],
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
|
@ -94,16 +94,16 @@ export const album = pgTable("album", {
|
|||
export const albumImage = pgTable(
|
||||
"album_image",
|
||||
{
|
||||
album: uuid()
|
||||
albumId: uuid()
|
||||
.references(() => album.id)
|
||||
.notNull(),
|
||||
image: uuid()
|
||||
imageId: uuid()
|
||||
.references(() => platformImage.id)
|
||||
.notNull(),
|
||||
},
|
||||
(albumImage) => [
|
||||
primaryKey({
|
||||
columns: [albumImage.album, albumImage.image],
|
||||
columns: [albumImage.albumId, albumImage.imageId],
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
|
@ -111,16 +111,16 @@ export const albumImage = pgTable(
|
|||
export const albumArtist = pgTable(
|
||||
"album_artist",
|
||||
{
|
||||
album: uuid()
|
||||
albumId: uuid()
|
||||
.references(() => album.id)
|
||||
.notNull(),
|
||||
artist: uuid()
|
||||
artistId: uuid()
|
||||
.references(() => artist.id)
|
||||
.notNull(),
|
||||
},
|
||||
(albumArtist) => [
|
||||
primaryKey({
|
||||
columns: [albumArtist.album, albumArtist.artist],
|
||||
columns: [albumArtist.albumId, albumArtist.artistId],
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
|
@ -128,16 +128,16 @@ export const albumArtist = pgTable(
|
|||
export const albumGenre = pgTable(
|
||||
"album_genre",
|
||||
{
|
||||
album: uuid()
|
||||
albumId: uuid()
|
||||
.references(() => album.id)
|
||||
.notNull(),
|
||||
genre: uuid()
|
||||
genreId: uuid()
|
||||
.references(() => genre.id)
|
||||
.notNull(),
|
||||
},
|
||||
(albumGenre) => [
|
||||
primaryKey({
|
||||
columns: [albumGenre.album, albumGenre.genre],
|
||||
columns: [albumGenre.albumId, albumGenre.genreId],
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
|
@ -146,7 +146,7 @@ export const track = pgTable(
|
|||
"track",
|
||||
{
|
||||
id: uuid().defaultRandom().primaryKey().notNull(),
|
||||
album: uuid()
|
||||
albumId: uuid()
|
||||
.references(() => album.id)
|
||||
.notNull(),
|
||||
name: text(),
|
||||
|
|
@ -158,29 +158,29 @@ export const track = pgTable(
|
|||
disc_number: integer(),
|
||||
track_number: integer(),
|
||||
},
|
||||
(track) => [index().on([track.album])],
|
||||
(track) => [index().on(track.albumId)],
|
||||
);
|
||||
|
||||
export const trackArtist = pgTable(
|
||||
"track_artist",
|
||||
{
|
||||
track: uuid()
|
||||
trackId: uuid()
|
||||
.references(() => track.id)
|
||||
.notNull(),
|
||||
artist: uuid()
|
||||
artistId: uuid()
|
||||
.references(() => artist.id)
|
||||
.notNull(),
|
||||
},
|
||||
(trackArtist) => [
|
||||
primaryKey({
|
||||
columns: [trackArtist.track, trackArtist.artist],
|
||||
columns: [trackArtist.trackId, trackArtist.artistId],
|
||||
}),
|
||||
index().on([trackArtist.track]),
|
||||
index().on([trackArtist.artist]),
|
||||
index("track_artist_track_id_idx").on(trackArtist.trackId),
|
||||
index("track_artist_artist_id_idx").on(trackArtist.artistId),
|
||||
],
|
||||
);
|
||||
|
||||
const topTimeline = pgEnum("top_timeline", [
|
||||
export const topTimeline = pgEnum("top_timeline", [
|
||||
"short_term",
|
||||
"medium_term",
|
||||
"long_term",
|
||||
|
|
@ -189,96 +189,96 @@ const topTimeline = pgEnum("top_timeline", [
|
|||
export const topTrack = pgTable(
|
||||
"top_track",
|
||||
{
|
||||
track: uuid()
|
||||
trackId: uuid()
|
||||
.references(() => track.id)
|
||||
.notNull(),
|
||||
position: integer().notNull(),
|
||||
user: text()
|
||||
userId: text()
|
||||
.references(() => user.id)
|
||||
.notNull(),
|
||||
timeline: topTimeline().notNull(),
|
||||
},
|
||||
(topTrack) => [
|
||||
primaryKey({
|
||||
columns: [topTrack.track, topTrack.user, topTrack.timeline],
|
||||
columns: [topTrack.trackId, topTrack.userId, topTrack.timeline],
|
||||
}),
|
||||
index().on([topTrack.user]),
|
||||
index().on(topTrack.userId),
|
||||
],
|
||||
);
|
||||
|
||||
export const topArtist = pgTable(
|
||||
"top_artist",
|
||||
{
|
||||
artist: uuid()
|
||||
artistId: uuid()
|
||||
.references(() => artist.id)
|
||||
.notNull(),
|
||||
position: integer().notNull(),
|
||||
user: text()
|
||||
userId: text()
|
||||
.references(() => user.id)
|
||||
.notNull(),
|
||||
timeline: topTimeline().notNull(),
|
||||
},
|
||||
(topArtist) => [
|
||||
primaryKey({
|
||||
columns: [topArtist.artist, topArtist.user, topArtist.timeline],
|
||||
columns: [topArtist.artistId, topArtist.userId, topArtist.timeline],
|
||||
}),
|
||||
index().on([topArtist.user]),
|
||||
index().on(topArtist.userId),
|
||||
],
|
||||
);
|
||||
|
||||
export const savedAlbum = pgTable(
|
||||
"saved_album",
|
||||
{
|
||||
album: uuid()
|
||||
albumId: uuid()
|
||||
.references(() => album.id)
|
||||
.notNull(),
|
||||
user: text()
|
||||
userId: text()
|
||||
.references(() => user.id)
|
||||
.notNull(),
|
||||
saved_at: timestamp("saved_at").defaultNow().notNull(),
|
||||
},
|
||||
(savedAlbum) => [
|
||||
primaryKey({
|
||||
columns: [savedAlbum.album, savedAlbum.user],
|
||||
columns: [savedAlbum.albumId, savedAlbum.userId],
|
||||
}),
|
||||
index().on([savedAlbum.user]),
|
||||
index().on(savedAlbum.userId),
|
||||
],
|
||||
);
|
||||
|
||||
export const savedTrack = pgTable(
|
||||
"saved_track",
|
||||
{
|
||||
track: uuid()
|
||||
trackId: uuid()
|
||||
.references(() => track.id)
|
||||
.notNull(),
|
||||
user: text()
|
||||
userId: text()
|
||||
.references(() => user.id)
|
||||
.notNull(),
|
||||
saved_at: timestamp("saved_at").defaultNow().notNull(),
|
||||
},
|
||||
(savedTrack) => [
|
||||
primaryKey({
|
||||
columns: [savedTrack.track, savedTrack.user],
|
||||
columns: [savedTrack.trackId, savedTrack.userId],
|
||||
}),
|
||||
index().on([savedTrack.user]),
|
||||
index().on(savedTrack.userId),
|
||||
],
|
||||
);
|
||||
|
||||
export const followedArtist = pgTable(
|
||||
"followed_artist",
|
||||
{
|
||||
artist: uuid()
|
||||
artistId: uuid()
|
||||
.references(() => artist.id)
|
||||
.notNull(),
|
||||
user: text()
|
||||
userId: text()
|
||||
.references(() => user.id)
|
||||
.notNull(),
|
||||
},
|
||||
(followedArtist) => [
|
||||
primaryKey({
|
||||
columns: [followedArtist.artist, followedArtist.user],
|
||||
columns: [followedArtist.artistId, followedArtist.userId],
|
||||
}),
|
||||
index().on([followedArtist.user]),
|
||||
index().on(followedArtist.userId),
|
||||
],
|
||||
);
|
||||
|
||||
|
|
@ -286,15 +286,15 @@ export const playbackHistory = pgTable(
|
|||
"playback_history",
|
||||
{
|
||||
id: uuid().defaultRandom().primaryKey().notNull(),
|
||||
track: uuid()
|
||||
trackId: uuid()
|
||||
.references(() => track.id)
|
||||
.notNull(),
|
||||
user: text()
|
||||
userId: text()
|
||||
.references(() => user.id)
|
||||
.notNull(),
|
||||
played_at: timestamp("played_at").defaultNow().notNull(),
|
||||
},
|
||||
(playbackHistory) => [index().on([playbackHistory.user])],
|
||||
(playbackHistory) => [index().on(playbackHistory.userId)],
|
||||
);
|
||||
|
||||
export const relations = defineRelations(
|
||||
|
|
@ -326,43 +326,42 @@ export const relations = defineRelations(
|
|||
albumArtists: r.many.albumArtist(),
|
||||
topArtists: r.many.topArtist(),
|
||||
followedArtists: r.many.followedArtist(),
|
||||
albumGenres: r.many.albumGenre(),
|
||||
genres: r.many.genre({
|
||||
from: r.artist.id.through(r.artistGenre.artist),
|
||||
to: r.genre.id.through(r.artistGenre.genre),
|
||||
from: r.artist.id.through(r.artistGenre.artistId),
|
||||
to: r.genre.id.through(r.artistGenre.genreId),
|
||||
}),
|
||||
images: r.many.platformImage({
|
||||
from: r.artist.id.through(r.artistImage.artist),
|
||||
to: r.platformImage.id.through(r.artistImage.image),
|
||||
from: r.artist.id.through(r.artistImage.artistId),
|
||||
to: r.platformImage.id.through(r.artistImage.imageId),
|
||||
}),
|
||||
albums: r.many.album({
|
||||
from: r.artist.id.through(r.albumArtist.artist),
|
||||
to: r.album.id.through(r.albumArtist.album),
|
||||
from: r.artist.id.through(r.albumArtist.artistId),
|
||||
to: r.album.id.through(r.albumArtist.albumId),
|
||||
}),
|
||||
tracks: r.many.track({
|
||||
from: r.artist.id.through(r.trackArtist.artist),
|
||||
to: r.track.id.through(r.trackArtist.track),
|
||||
from: r.artist.id.through(r.trackArtist.artistId),
|
||||
to: r.track.id.through(r.trackArtist.trackId),
|
||||
}),
|
||||
},
|
||||
genre: {
|
||||
artistGenres: r.many.artistGenre(),
|
||||
albumGenres: r.many.albumGenre(),
|
||||
artists: r.many.artist({
|
||||
from: r.genre.id.through(r.artistGenre.genre),
|
||||
to: r.artist.id.through(r.artistGenre.artist),
|
||||
from: r.genre.id.through(r.artistGenre.genreId),
|
||||
to: r.artist.id.through(r.artistGenre.artistId),
|
||||
}),
|
||||
albums: r.many.album({
|
||||
from: r.genre.id.through(r.albumGenre.genre),
|
||||
to: r.album.id.through(r.albumGenre.album),
|
||||
from: r.genre.id.through(r.albumGenre.genreId),
|
||||
to: r.album.id.through(r.albumGenre.albumId),
|
||||
}),
|
||||
},
|
||||
artistGenre: {
|
||||
artist: r.one.artist({
|
||||
from: r.artistGenre.artist,
|
||||
from: r.artistGenre.artistId,
|
||||
to: r.artist.id,
|
||||
}),
|
||||
genre: r.one.genre({
|
||||
from: r.artistGenre.genre,
|
||||
from: r.artistGenre.genreId,
|
||||
to: r.genre.id,
|
||||
}),
|
||||
},
|
||||
|
|
@ -370,21 +369,21 @@ export const relations = defineRelations(
|
|||
artistImages: r.many.artistImage(),
|
||||
albumImages: r.many.albumImage(),
|
||||
artists: r.many.artist({
|
||||
from: r.platformImage.id.through(r.artistImage.image),
|
||||
to: r.artist.id.through(r.artistImage.artist),
|
||||
from: r.platformImage.id.through(r.artistImage.imageId),
|
||||
to: r.artist.id.through(r.artistImage.artistId),
|
||||
}),
|
||||
albums: r.many.album({
|
||||
from: r.platformImage.id.through(r.albumImage.image),
|
||||
to: r.album.id.through(r.albumImage.album),
|
||||
from: r.platformImage.id.through(r.albumImage.imageId),
|
||||
to: r.album.id.through(r.albumImage.albumId),
|
||||
}),
|
||||
},
|
||||
artistImage: {
|
||||
artist: r.one.artist({
|
||||
from: r.artistImage.artist,
|
||||
from: r.artistImage.artistId,
|
||||
to: r.artist.id,
|
||||
}),
|
||||
image: r.one.platformImage({
|
||||
from: r.artistImage.image,
|
||||
from: r.artistImage.imageId,
|
||||
to: r.platformImage.id,
|
||||
}),
|
||||
},
|
||||
|
|
@ -395,51 +394,51 @@ export const relations = defineRelations(
|
|||
albumGenres: r.many.albumGenre(),
|
||||
savedAlbums: r.many.savedAlbum(),
|
||||
images: r.many.platformImage({
|
||||
from: r.album.id.through(r.albumImage.album),
|
||||
to: r.platformImage.id.through(r.albumImage.image),
|
||||
from: r.album.id.through(r.albumImage.albumId),
|
||||
to: r.platformImage.id.through(r.albumImage.imageId),
|
||||
}),
|
||||
artists: r.many.artist({
|
||||
from: r.album.id.through(r.albumArtist.album),
|
||||
to: r.artist.id.through(r.albumArtist.artist),
|
||||
from: r.album.id.through(r.albumArtist.albumId),
|
||||
to: r.artist.id.through(r.albumArtist.artistId),
|
||||
}),
|
||||
genres: r.many.genre({
|
||||
from: r.album.id.through(r.albumGenre.album),
|
||||
to: r.genre.id.through(r.albumGenre.genre),
|
||||
from: r.album.id.through(r.albumGenre.albumId),
|
||||
to: r.genre.id.through(r.albumGenre.genreId),
|
||||
}),
|
||||
},
|
||||
albumImage: {
|
||||
album: r.one.album({
|
||||
from: r.albumImage.album,
|
||||
from: r.albumImage.albumId,
|
||||
to: r.album.id,
|
||||
}),
|
||||
image: r.one.platformImage({
|
||||
from: r.albumImage.image,
|
||||
from: r.albumImage.imageId,
|
||||
to: r.platformImage.id,
|
||||
}),
|
||||
},
|
||||
albumArtist: {
|
||||
album: r.one.album({
|
||||
from: r.albumArtist.album,
|
||||
from: r.albumArtist.albumId,
|
||||
to: r.album.id,
|
||||
}),
|
||||
artist: r.one.artist({
|
||||
from: r.albumArtist.artist,
|
||||
from: r.albumArtist.artistId,
|
||||
to: r.artist.id,
|
||||
}),
|
||||
},
|
||||
albumGenre: {
|
||||
album: r.one.album({
|
||||
from: r.albumGenre.album,
|
||||
from: r.albumGenre.albumId,
|
||||
to: r.album.id,
|
||||
}),
|
||||
genre: r.one.genre({
|
||||
from: r.albumGenre.genre,
|
||||
from: r.albumGenre.genreId,
|
||||
to: r.genre.id,
|
||||
}),
|
||||
},
|
||||
track: {
|
||||
album: r.one.album({
|
||||
from: r.track.album,
|
||||
from: r.track.albumId,
|
||||
to: r.album.id,
|
||||
}),
|
||||
trackArtists: r.many.trackArtist(),
|
||||
|
|
@ -447,77 +446,77 @@ export const relations = defineRelations(
|
|||
savedTracks: r.many.savedTrack(),
|
||||
playbackHistory: r.many.playbackHistory(),
|
||||
artists: r.many.artist({
|
||||
from: r.track.id.through(r.trackArtist.track),
|
||||
to: r.artist.id.through(r.trackArtist.artist),
|
||||
from: r.track.id.through(r.trackArtist.trackId),
|
||||
to: r.artist.id.through(r.trackArtist.artistId),
|
||||
}),
|
||||
},
|
||||
trackArtist: {
|
||||
track: r.one.track({
|
||||
from: r.trackArtist.track,
|
||||
from: r.trackArtist.trackId,
|
||||
to: r.track.id,
|
||||
}),
|
||||
artist: r.one.artist({
|
||||
from: r.trackArtist.artist,
|
||||
from: r.trackArtist.artistId,
|
||||
to: r.artist.id,
|
||||
}),
|
||||
},
|
||||
topTrack: {
|
||||
track: r.one.track({
|
||||
from: r.topTrack.track,
|
||||
from: r.topTrack.trackId,
|
||||
to: r.track.id,
|
||||
}),
|
||||
user: r.one.user({
|
||||
from: r.topTrack.user,
|
||||
from: r.topTrack.userId,
|
||||
to: r.user.id,
|
||||
}),
|
||||
},
|
||||
topArtist: {
|
||||
artist: r.one.artist({
|
||||
from: r.topArtist.artist,
|
||||
from: r.topArtist.artistId,
|
||||
to: r.artist.id,
|
||||
}),
|
||||
user: r.one.user({
|
||||
from: r.topArtist.user,
|
||||
from: r.topArtist.userId,
|
||||
to: r.user.id,
|
||||
}),
|
||||
},
|
||||
savedAlbum: {
|
||||
album: r.one.album({
|
||||
from: r.savedAlbum.album,
|
||||
from: r.savedAlbum.albumId,
|
||||
to: r.album.id,
|
||||
}),
|
||||
user: r.one.user({
|
||||
from: r.savedAlbum.user,
|
||||
from: r.savedAlbum.userId,
|
||||
to: r.user.id,
|
||||
}),
|
||||
},
|
||||
savedTrack: {
|
||||
track: r.one.track({
|
||||
from: r.savedTrack.track,
|
||||
from: r.savedTrack.trackId,
|
||||
to: r.track.id,
|
||||
}),
|
||||
user: r.one.user({
|
||||
from: r.savedTrack.user,
|
||||
from: r.savedTrack.userId,
|
||||
to: r.user.id,
|
||||
}),
|
||||
},
|
||||
followedArtist: {
|
||||
artist: r.one.artist({
|
||||
from: r.followedArtist.artist,
|
||||
from: r.followedArtist.artistId,
|
||||
to: r.artist.id,
|
||||
}),
|
||||
user: r.one.user({
|
||||
from: r.followedArtist.user,
|
||||
from: r.followedArtist.userId,
|
||||
to: r.user.id,
|
||||
}),
|
||||
},
|
||||
playbackHistory: {
|
||||
track: r.one.track({
|
||||
from: r.playbackHistory.track,
|
||||
from: r.playbackHistory.trackId,
|
||||
to: r.track.id,
|
||||
}),
|
||||
user: r.one.user({
|
||||
from: r.playbackHistory.user,
|
||||
from: r.playbackHistory.userId,
|
||||
to: r.user.id,
|
||||
}),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import type {
|
||||
Album,
|
||||
Artist,
|
||||
FollowedArtists,
|
||||
Image,
|
||||
PlayHistory,
|
||||
SavedAlbum,
|
||||
|
|
@ -35,8 +34,16 @@ import { defaultSdk } from "../auth";
|
|||
|
||||
export const PLATFORM_SPOTIFY = "spotify" as const;
|
||||
|
||||
export async function upsertImages(images: Image[]) {
|
||||
await db.insert(platformImage).values(
|
||||
type DbClient = typeof db;
|
||||
type DbTransaction = Parameters<typeof db.transaction>[0] extends (
|
||||
tx: infer T,
|
||||
) => Promise<any>
|
||||
? T
|
||||
: never;
|
||||
type DbLike = DbClient | DbTransaction;
|
||||
|
||||
export async function upsertImages(images: Image[], dbClient: DbLike = db) {
|
||||
await dbClient.insert(platformImage).values(
|
||||
images.map(({ url, height, width }) => ({
|
||||
platform: PLATFORM_SPOTIFY,
|
||||
url,
|
||||
|
|
@ -46,12 +53,12 @@ export async function upsertImages(images: Image[]) {
|
|||
);
|
||||
}
|
||||
|
||||
export async function upsertGenres(genres: string[]) {
|
||||
await db.insert(genre).values(genres.map((name) => ({ name })));
|
||||
export async function upsertGenres(genres: string[], dbClient: DbLike = db) {
|
||||
await dbClient.insert(genre).values(genres.map((name) => ({ name })));
|
||||
}
|
||||
|
||||
export async function upsertArtists(artists: Artist[]) {
|
||||
await db.insert(artist).values(
|
||||
export async function upsertArtists(artists: Artist[], dbClient: DbLike = db) {
|
||||
await dbClient.insert(artist).values(
|
||||
artists.map(({ id, name, images, genres, popularity, type }) => ({
|
||||
platform: PLATFORM_SPOTIFY,
|
||||
platform_id: id,
|
||||
|
|
@ -60,14 +67,20 @@ export async function upsertArtists(artists: Artist[]) {
|
|||
type,
|
||||
})),
|
||||
);
|
||||
await upsertImages(artists.flatMap((a) => a.images));
|
||||
await upsertGenres(artists.flatMap((a) => a.genres));
|
||||
await upsertImages(
|
||||
artists.flatMap((a) => a.images),
|
||||
dbClient,
|
||||
);
|
||||
await upsertGenres(
|
||||
artists.flatMap((a) => a.genres),
|
||||
dbClient,
|
||||
);
|
||||
for (const spotifyArtist of artists) {
|
||||
await db.insert(artistImage).select(
|
||||
db
|
||||
await dbClient.insert(artistImage).select(
|
||||
dbClient
|
||||
.select({
|
||||
artist: artist.id,
|
||||
image: platformImage.id,
|
||||
artistId: artist.id,
|
||||
imageId: platformImage.id,
|
||||
})
|
||||
.from(platformImage)
|
||||
.where(
|
||||
|
|
@ -81,11 +94,11 @@ export async function upsertArtists(artists: Artist[]) {
|
|||
)
|
||||
.innerJoin(artist, eq(artist.platform_id, spotifyArtist.id)),
|
||||
);
|
||||
await db.insert(artistGenre).select(
|
||||
db
|
||||
await dbClient.insert(artistGenre).select(
|
||||
dbClient
|
||||
.select({
|
||||
artist: artist.id,
|
||||
genre: genre.id,
|
||||
artistId: artist.id,
|
||||
genreId: genre.id,
|
||||
})
|
||||
.from(genre)
|
||||
.where(inArray(genre.name, spotifyArtist.genres))
|
||||
|
|
@ -94,19 +107,22 @@ export async function upsertArtists(artists: Artist[]) {
|
|||
}
|
||||
}
|
||||
|
||||
async function getMissingArtists(artistIds: string[]) {
|
||||
const existingArtists = await db
|
||||
async function getMissingArtists(artistIds: string[], dbClient: DbLike = db) {
|
||||
const existingArtists = await dbClient
|
||||
.select({ id: artist.id })
|
||||
.from(artist)
|
||||
.where(inArray(artist.platform_id, artistIds));
|
||||
return artistIds.filter((id) => !existingArtists.some((a) => a.id === id));
|
||||
}
|
||||
|
||||
async function lookupMissingArtists(artistIds: string[]) {
|
||||
const missingArtistIds = await getMissingArtists(artistIds);
|
||||
async function lookupMissingArtists(
|
||||
artistIds: string[],
|
||||
dbClient: DbLike = db,
|
||||
) {
|
||||
const missingArtistIds = await getMissingArtists(artistIds, dbClient);
|
||||
if (missingArtistIds.length === 0) return [];
|
||||
const missingArtists = await defaultSdk.artists.get(missingArtistIds);
|
||||
await upsertArtists(missingArtists);
|
||||
await upsertArtists(missingArtists, dbClient);
|
||||
return missingArtists;
|
||||
}
|
||||
|
||||
|
|
@ -116,51 +132,66 @@ function isFullArtistArray(
|
|||
return "images" in artists[0]!;
|
||||
}
|
||||
|
||||
async function upsertMissingArtists(artists: SimplifiedArtist[] | Artist[]) {
|
||||
async function upsertMissingArtists(
|
||||
artists: SimplifiedArtist[] | Artist[],
|
||||
dbClient: DbLike = db,
|
||||
) {
|
||||
if (artists.length === 0) return;
|
||||
let missingArtists: Artist[];
|
||||
if (isFullArtistArray(artists)) {
|
||||
const missingArtistIds = await getMissingArtists(artists.map((t) => t.id));
|
||||
const missingArtistIds = await getMissingArtists(
|
||||
artists.map((t) => t.id),
|
||||
dbClient,
|
||||
);
|
||||
if (missingArtistIds.length === 0) return;
|
||||
missingArtists = artists.filter((a) => missingArtistIds.includes(a.id));
|
||||
} else {
|
||||
missingArtists = await lookupMissingArtists(artists.map((t) => t.id));
|
||||
missingArtists = await lookupMissingArtists(
|
||||
artists.map((t) => t.id),
|
||||
dbClient,
|
||||
);
|
||||
}
|
||||
if (missingArtists.length === 0) return;
|
||||
await upsertArtists(missingArtists);
|
||||
await upsertArtists(missingArtists, dbClient);
|
||||
return missingArtists;
|
||||
}
|
||||
|
||||
async function getArtistIdMap(artistIds: string[]) {
|
||||
async function getArtistIdMap(artistIds: string[], dbClient: DbLike = db) {
|
||||
if (artistIds.length === 0) return new Map<string, string>();
|
||||
const rows = await db
|
||||
const rows = await dbClient
|
||||
.select({ id: artist.id, platform_id: artist.platform_id })
|
||||
.from(artist)
|
||||
.where(inArray(artist.platform_id, artistIds));
|
||||
return new Map(rows.map((row) => [row.platform_id, row.id]));
|
||||
}
|
||||
|
||||
async function getAlbumIdMap(albumIds: string[]) {
|
||||
async function getAlbumIdMap(albumIds: string[], dbClient: DbLike = db) {
|
||||
if (albumIds.length === 0) return new Map<string, string>();
|
||||
const rows = await db
|
||||
const rows = await dbClient
|
||||
.select({ id: album.id, platform_id: album.platform_id })
|
||||
.from(album)
|
||||
.where(inArray(album.platform_id, albumIds));
|
||||
return new Map(rows.map((row) => [row.platform_id ?? "", row.id]));
|
||||
}
|
||||
|
||||
async function getTrackIdMap(trackIds: string[]) {
|
||||
async function getTrackIdMap(trackIds: string[], dbClient: DbLike = db) {
|
||||
if (trackIds.length === 0) return new Map<string, string>();
|
||||
const rows = await db
|
||||
const rows = await dbClient
|
||||
.select({ id: track.id, platform_id: track.platform_id })
|
||||
.from(track)
|
||||
.where(inArray(track.platform_id, trackIds));
|
||||
return new Map(rows.map((row) => [row.platform_id ?? "", row.id]));
|
||||
}
|
||||
|
||||
export async function upsertAlbums(albums: Album[] | SimplifiedAlbum[]) {
|
||||
await upsertMissingArtists(albums.flatMap((a) => a.artists));
|
||||
await db.insert(album).values(
|
||||
export async function upsertAlbums(
|
||||
albums: Album[] | SimplifiedAlbum[],
|
||||
dbClient: DbLike = db,
|
||||
) {
|
||||
await upsertMissingArtists(
|
||||
albums.flatMap((a) => a.artists),
|
||||
dbClient,
|
||||
);
|
||||
await dbClient.insert(album).values(
|
||||
albums.map(({ id, name, type, popularity, release_date, label }) => ({
|
||||
platform: PLATFORM_SPOTIFY,
|
||||
platform_id: id,
|
||||
|
|
@ -171,14 +202,20 @@ export async function upsertAlbums(albums: Album[] | SimplifiedAlbum[]) {
|
|||
label,
|
||||
})),
|
||||
);
|
||||
await upsertImages(albums.flatMap((a) => a.images));
|
||||
await upsertGenres(albums.flatMap((a) => a.genres));
|
||||
await upsertImages(
|
||||
albums.flatMap((a) => a.images),
|
||||
dbClient,
|
||||
);
|
||||
await upsertGenres(
|
||||
albums.flatMap((a) => a.genres),
|
||||
dbClient,
|
||||
);
|
||||
for (const spotifyAlbum of albums) {
|
||||
await db.insert(albumImage).select(
|
||||
db
|
||||
await dbClient.insert(albumImage).select(
|
||||
dbClient
|
||||
.select({
|
||||
album: album.id,
|
||||
image: platformImage.id,
|
||||
albumId: album.id,
|
||||
imageId: platformImage.id,
|
||||
})
|
||||
.from(platformImage)
|
||||
.where(
|
||||
|
|
@ -192,21 +229,26 @@ export async function upsertAlbums(albums: Album[] | SimplifiedAlbum[]) {
|
|||
)
|
||||
.innerJoin(album, eq(album.platform_id, spotifyAlbum.id)),
|
||||
);
|
||||
await db.insert(albumArtist).select(
|
||||
db
|
||||
await dbClient.insert(albumArtist).select(
|
||||
dbClient
|
||||
.select({
|
||||
album: album.id,
|
||||
artist: artist.id,
|
||||
albumId: album.id,
|
||||
artistId: artist.id,
|
||||
})
|
||||
.from(artist)
|
||||
.where(inArray(artist.platform_id, spotifyAlbum.artists.map((t) => t.id)))
|
||||
.where(
|
||||
inArray(
|
||||
artist.platform_id,
|
||||
spotifyAlbum.artists.map((t) => t.id),
|
||||
),
|
||||
)
|
||||
.innerJoin(album, eq(album.platform_id, spotifyAlbum.id)),
|
||||
);
|
||||
await db.insert(albumGenre).select(
|
||||
db
|
||||
await dbClient.insert(albumGenre).select(
|
||||
dbClient
|
||||
.select({
|
||||
album: album.id,
|
||||
genre: genre.id,
|
||||
albumId: album.id,
|
||||
genreId: genre.id,
|
||||
})
|
||||
.from(genre)
|
||||
.where(inArray(genre.name, spotifyAlbum.genres))
|
||||
|
|
@ -215,14 +257,23 @@ export async function upsertAlbums(albums: Album[] | SimplifiedAlbum[]) {
|
|||
}
|
||||
}
|
||||
|
||||
export async function upsertTracks(tracks: Track[]) {
|
||||
export async function upsertTracks(tracks: Track[], dbClient: DbLike = db) {
|
||||
if (tracks.length === 0) return;
|
||||
await upsertAlbums(tracks.map((t) => t.album));
|
||||
await upsertMissingArtists(tracks.flatMap((t) => t.artists));
|
||||
const albumIdMap = await getAlbumIdMap(tracks.map((t) => t.album.id));
|
||||
await db.insert(track).values(
|
||||
await upsertAlbums(
|
||||
tracks.map((t) => t.album),
|
||||
dbClient,
|
||||
);
|
||||
await upsertMissingArtists(
|
||||
tracks.flatMap((t) => t.artists),
|
||||
dbClient,
|
||||
);
|
||||
const albumIdMap = await getAlbumIdMap(
|
||||
tracks.map((t) => t.album.id),
|
||||
dbClient,
|
||||
);
|
||||
await dbClient.insert(track).values(
|
||||
tracks.map((spotifyTrack) => ({
|
||||
album: albumIdMap.get(spotifyTrack.album.id)!,
|
||||
albumId: albumIdMap.get(spotifyTrack.album.id)!,
|
||||
name: spotifyTrack.name,
|
||||
platform: PLATFORM_SPOTIFY,
|
||||
platform_id: spotifyTrack.id,
|
||||
|
|
@ -234,14 +285,19 @@ export async function upsertTracks(tracks: Track[]) {
|
|||
})),
|
||||
);
|
||||
for (const spotifyTrack of tracks) {
|
||||
await db.insert(trackArtist).select(
|
||||
db
|
||||
await dbClient.insert(trackArtist).select(
|
||||
dbClient
|
||||
.select({
|
||||
track: track.id,
|
||||
artist: artist.id,
|
||||
trackId: track.id,
|
||||
artistId: artist.id,
|
||||
})
|
||||
.from(artist)
|
||||
.where(inArray(artist.platform_id, spotifyTrack.artists.map((t) => t.id)))
|
||||
.where(
|
||||
inArray(
|
||||
artist.platform_id,
|
||||
spotifyTrack.artists.map((t) => t.id),
|
||||
),
|
||||
)
|
||||
.innerJoin(track, eq(track.platform_id, spotifyTrack.id)),
|
||||
);
|
||||
}
|
||||
|
|
@ -251,15 +307,19 @@ export async function upsertTopArtists(
|
|||
userId: string,
|
||||
timeline: "short_term" | "medium_term" | "long_term",
|
||||
artists: Artist[],
|
||||
dbClient: DbLike = db,
|
||||
) {
|
||||
if (artists.length === 0) return;
|
||||
await upsertArtists(artists);
|
||||
const artistIdMap = await getArtistIdMap(artists.map((t) => t.id));
|
||||
await db.insert(topArtist).values(
|
||||
await upsertArtists(artists, dbClient);
|
||||
const artistIdMap = await getArtistIdMap(
|
||||
artists.map((t) => t.id),
|
||||
dbClient,
|
||||
);
|
||||
await dbClient.insert(topArtist).values(
|
||||
artists.map((spotifyArtist, index) => ({
|
||||
artist: artistIdMap.get(spotifyArtist.id)!,
|
||||
artistId: artistIdMap.get(spotifyArtist.id)!,
|
||||
position: index + 1,
|
||||
user: userId,
|
||||
userId,
|
||||
timeline,
|
||||
})),
|
||||
);
|
||||
|
|
@ -269,43 +329,61 @@ export async function upsertTopTracks(
|
|||
userId: string,
|
||||
timeline: "short_term" | "medium_term" | "long_term",
|
||||
tracks: Track[],
|
||||
dbClient: DbLike = db,
|
||||
) {
|
||||
if (tracks.length === 0) return;
|
||||
await upsertTracks(tracks);
|
||||
const trackIdMap = await getTrackIdMap(tracks.map((t) => t.id));
|
||||
await db.insert(topTrack).values(
|
||||
await upsertTracks(tracks, dbClient);
|
||||
const trackIdMap = await getTrackIdMap(
|
||||
tracks.map((t) => t.id),
|
||||
dbClient,
|
||||
);
|
||||
await dbClient.insert(topTrack).values(
|
||||
tracks.map((spotifyTrack, index) => ({
|
||||
track: trackIdMap.get(spotifyTrack.id)!,
|
||||
trackId: trackIdMap.get(spotifyTrack.id)!,
|
||||
position: index + 1,
|
||||
user: userId,
|
||||
userId,
|
||||
timeline,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
export async function upsertSavedAlbums(userId: string, saved: SavedAlbum[]) {
|
||||
export async function upsertSavedAlbums(
|
||||
userId: string,
|
||||
saved: SavedAlbum[],
|
||||
dbClient: DbLike = db,
|
||||
) {
|
||||
if (saved.length === 0) return;
|
||||
const albums = saved.map((item) => item.album);
|
||||
await upsertAlbums(albums);
|
||||
const albumIdMap = await getAlbumIdMap(albums.map((t) => t.id));
|
||||
await db.insert(savedAlbum).values(
|
||||
await upsertAlbums(albums, dbClient);
|
||||
const albumIdMap = await getAlbumIdMap(
|
||||
albums.map((t) => t.id),
|
||||
dbClient,
|
||||
);
|
||||
await dbClient.insert(savedAlbum).values(
|
||||
saved.map((item) => ({
|
||||
album: albumIdMap.get(item.album.id)!,
|
||||
user: userId,
|
||||
albumId: albumIdMap.get(item.album.id)!,
|
||||
userId,
|
||||
saved_at: new Date(item.added_at),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
export async function upsertSavedTracks(userId: string, saved: SavedTrack[]) {
|
||||
export async function upsertSavedTracks(
|
||||
userId: string,
|
||||
saved: SavedTrack[],
|
||||
dbClient: DbLike = db,
|
||||
) {
|
||||
if (saved.length === 0) return;
|
||||
const tracks = saved.map((item) => item.track);
|
||||
await upsertTracks(tracks);
|
||||
const trackIdMap = await getTrackIdMap(tracks.map((t) => t.id));
|
||||
await db.insert(savedTrack).values(
|
||||
await upsertTracks(tracks, dbClient);
|
||||
const trackIdMap = await getTrackIdMap(
|
||||
tracks.map((t) => t.id),
|
||||
dbClient,
|
||||
);
|
||||
await dbClient.insert(savedTrack).values(
|
||||
saved.map((item) => ({
|
||||
track: trackIdMap.get(item.track.id)!,
|
||||
user: userId,
|
||||
trackId: trackIdMap.get(item.track.id)!,
|
||||
userId,
|
||||
saved_at: new Date(item.added_at),
|
||||
})),
|
||||
);
|
||||
|
|
@ -313,16 +391,19 @@ export async function upsertSavedTracks(userId: string, saved: SavedTrack[]) {
|
|||
|
||||
export async function upsertFollowedArtists(
|
||||
userId: string,
|
||||
followed: FollowedArtists,
|
||||
artists: Artist[],
|
||||
dbClient: DbLike = db,
|
||||
) {
|
||||
const artists = followed.artists.items;
|
||||
if (artists.length === 0) return;
|
||||
await upsertArtists(artists);
|
||||
const artistIdMap = await getArtistIdMap(artists.map((t) => t.id));
|
||||
await db.insert(followedArtist).values(
|
||||
await upsertArtists(artists, dbClient);
|
||||
const artistIdMap = await getArtistIdMap(
|
||||
artists.map((t) => t.id),
|
||||
dbClient,
|
||||
);
|
||||
await dbClient.insert(followedArtist).values(
|
||||
artists.map((spotifyArtist) => ({
|
||||
artist: artistIdMap.get(spotifyArtist.id)!,
|
||||
user: userId,
|
||||
artistId: artistIdMap.get(spotifyArtist.id)!,
|
||||
userId,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
|
@ -330,15 +411,19 @@ export async function upsertFollowedArtists(
|
|||
export async function upsertPlaybackHistory(
|
||||
userId: string,
|
||||
items: PlayHistory[],
|
||||
dbClient: DbLike = db,
|
||||
) {
|
||||
if (items.length === 0) return;
|
||||
const tracks = items.map((item) => item.track);
|
||||
await upsertTracks(tracks);
|
||||
const trackIdMap = await getTrackIdMap(tracks.map((t) => t.id));
|
||||
await db.insert(playbackHistory).values(
|
||||
await upsertTracks(tracks, dbClient);
|
||||
const trackIdMap = await getTrackIdMap(
|
||||
tracks.map((t) => t.id),
|
||||
dbClient,
|
||||
);
|
||||
await dbClient.insert(playbackHistory).values(
|
||||
items.map((item) => ({
|
||||
track: trackIdMap.get(item.track.id)!,
|
||||
user: userId,
|
||||
trackId: trackIdMap.get(item.track.id)!,
|
||||
userId,
|
||||
played_at: new Date(item.played_at),
|
||||
})),
|
||||
);
|
||||
|
|
|
|||
7
api/src/dbos.ts
Normal file
7
api/src/dbos.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { DBOS } from "@dbos-inc/dbos-sdk";
|
||||
import "./workflows/sync";
|
||||
|
||||
DBOS.setConfig({
|
||||
name: "itpdp",
|
||||
systemDatabaseUrl: process.env.DATABASE_URL,
|
||||
});
|
||||
|
|
@ -1,7 +1,17 @@
|
|||
import { Elysia, t } from "elysia";
|
||||
import { betterAuthElysia } from "./auth";
|
||||
import { syncApp } from "./routes/sync";
|
||||
import { DBOS } from "@dbos-inc/dbos-sdk";
|
||||
import "./workflows/sync";
|
||||
import "./dbos.ts";
|
||||
|
||||
const app = new Elysia().use(betterAuthElysia).use(syncApp).listen(4000);
|
||||
const app = new Elysia()
|
||||
.use(betterAuthElysia)
|
||||
.group("/api", (app) => app.use(syncApp))
|
||||
.listen(4000);
|
||||
|
||||
export type App = typeof app;
|
||||
|
||||
await DBOS.launch({
|
||||
conductorKey: process.env.DBOS_CONDUCTOR_KEY,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,60 +1,11 @@
|
|||
import Elysia from "elysia";
|
||||
import { auth, betterAuthElysia, SPOTIFY_CLIENT_ID } from "../auth";
|
||||
import { SpotifyApi } from "@spotify/web-api-ts-sdk";
|
||||
import {
|
||||
upsertFollowedArtists,
|
||||
upsertPlaybackHistory,
|
||||
upsertSavedAlbums,
|
||||
upsertSavedTracks,
|
||||
upsertTopArtists,
|
||||
upsertTopTracks,
|
||||
} from "../db/spotify";
|
||||
import { betterAuthElysia } from "../auth";
|
||||
import { spotifySyncWorkflow } from "../workflows/sync";
|
||||
|
||||
export const syncApp = new Elysia().use(betterAuthElysia).post(
|
||||
"/sync",
|
||||
async ({ user }) => {
|
||||
const accessToken = await auth.api.getAccessToken({
|
||||
body: {
|
||||
userId: user.id,
|
||||
providerId: "spotify",
|
||||
},
|
||||
});
|
||||
const sdk = SpotifyApi.withAccessToken(SPOTIFY_CLIENT_ID, {
|
||||
access_token: accessToken.accessToken,
|
||||
expires_in: Date.now() - Number(accessToken.accessTokenExpiresAt!),
|
||||
expires: Number(accessToken.accessTokenExpiresAt),
|
||||
refresh_token: "",
|
||||
token_type: "",
|
||||
});
|
||||
for (const timeline of [
|
||||
"short_term",
|
||||
"medium_term",
|
||||
"long_term",
|
||||
] as const) {
|
||||
const topArtists = await sdk.currentUser.topItems(
|
||||
"artists",
|
||||
timeline,
|
||||
50,
|
||||
);
|
||||
await upsertTopArtists(user.id, timeline, topArtists.items);
|
||||
}
|
||||
for (const timeline of [
|
||||
"short_term",
|
||||
"medium_term",
|
||||
"long_term",
|
||||
] as const) {
|
||||
const topTracks = await sdk.currentUser.topItems("tracks", timeline, 50);
|
||||
await upsertTopTracks(user.id, timeline, topTracks.items);
|
||||
}
|
||||
const followedArtists = await sdk.currentUser.followedArtists();
|
||||
await upsertFollowedArtists(user.id, followedArtists);
|
||||
const savedAlbums = await sdk.currentUser.albums.savedAlbums(50);
|
||||
await upsertSavedAlbums(user.id, savedAlbums.items);
|
||||
const savedTracks = await sdk.currentUser.tracks.savedTracks(50);
|
||||
await upsertSavedTracks(user.id, savedTracks.items);
|
||||
const recentlyPlayed = await sdk.player.getRecentlyPlayedTracks(50);
|
||||
await upsertPlaybackHistory(user.id, recentlyPlayed.items);
|
||||
return { ok: true };
|
||||
return await spotifySyncWorkflow.syncUser(user.id);
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
|
|
|
|||
201
api/src/workflows/sync.ts
Normal file
201
api/src/workflows/sync.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import { DBOS, ConfiguredInstance } from "@dbos-inc/dbos-sdk";
|
||||
import { SpotifyApi } from "@spotify/web-api-ts-sdk";
|
||||
import type {
|
||||
Artist,
|
||||
PlayHistory,
|
||||
SavedAlbum,
|
||||
SavedTrack,
|
||||
Track,
|
||||
} from "@spotify/web-api-ts-sdk";
|
||||
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";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
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 {
|
||||
constructor(name: string) {
|
||||
super(name);
|
||||
}
|
||||
|
||||
@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,
|
||||
};
|
||||
}
|
||||
|
||||
@DBOS.step()
|
||||
private async persistSpotifyData(userId: string, data: SyncPayload) {
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.delete(topArtist).where(eq(topArtist.userId, userId));
|
||||
await tx.delete(topTrack).where(eq(topTrack.userId, userId));
|
||||
await tx.delete(savedAlbum).where(eq(savedAlbum.userId, userId));
|
||||
await tx.delete(savedTrack).where(eq(savedTrack.userId, userId));
|
||||
await tx.delete(followedArtist).where(eq(followedArtist.userId, userId));
|
||||
for (const timeline of timelines) {
|
||||
await upsertTopArtists(
|
||||
userId,
|
||||
timeline,
|
||||
data.topArtistsByTimeline[timeline],
|
||||
tx,
|
||||
);
|
||||
}
|
||||
for (const timeline of timelines) {
|
||||
await upsertTopTracks(
|
||||
userId,
|
||||
timeline,
|
||||
data.topTracksByTimeline[timeline],
|
||||
tx,
|
||||
);
|
||||
}
|
||||
await upsertFollowedArtists(userId, data.followedArtists, tx);
|
||||
await upsertSavedAlbums(userId, data.savedAlbums, tx);
|
||||
await upsertSavedTracks(userId, data.savedTracks, tx);
|
||||
await upsertPlaybackHistory(userId, data.recentlyPlayed, 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) break;
|
||||
after = artists.next;
|
||||
}
|
||||
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: Date.now() - Number(accessToken.accessTokenExpiresAt!),
|
||||
expires: Number(accessToken.accessTokenExpiresAt),
|
||||
refresh_token: "",
|
||||
token_type: "",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const spotifySyncWorkflow = new SpotifySyncWorkflow("spotify-sync");
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
"experimentalDecorators": true,
|
||||
|
||||
// Bundler mode
|
||||
"moduleResolution": "bundler",
|
||||
|
|
@ -24,6 +25,6 @@
|
|||
// Some stricter flags (disabled by default)
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false
|
||||
}
|
||||
"noPropertyAccessFromIndexSignature": false,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
27
bun.lock
27
bun.lock
|
|
@ -15,6 +15,7 @@
|
|||
"api": {
|
||||
"name": "api",
|
||||
"dependencies": {
|
||||
"@dbos-inc/dbos-sdk": "^4.14.6",
|
||||
"@spotify/web-api-ts-sdk": "^1.2.0",
|
||||
"@statsfm/statsfm.js": "github.com:statsfm/statsfm.js",
|
||||
"better-auth": "^1.6.5",
|
||||
|
|
@ -207,6 +208,8 @@
|
|||
|
||||
"@date-fns/tz": ["@date-fns/tz@1.4.1", "", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="],
|
||||
|
||||
"@dbos-inc/dbos-sdk": ["@dbos-inc/dbos-sdk@4.14.6", "", { "dependencies": { "commander": "12.0.0", "pg": "8.11.3", "serialize-error": "8.1.0", "superjson": "^1.13.3", "ws": "^8.18.1", "yaml": "^2.8.3" }, "bin": { "dbos": "dist/src/cli/cli.js", "dbos-sdk": "dist/src/cli/cli.js" } }, "sha512-uf7s8Z1fov3gAd5MLPK4SQX9sCgzJ4uF7W8Z5KZiEhUtCr0SsOJZb96Nse1YpXNohL5Npz1tEkNH9n+ThDlwRA=="],
|
||||
|
||||
"@dotenvx/dotenvx": ["@dotenvx/dotenvx@1.61.1", "", { "dependencies": { "commander": "^11.1.0", "dotenv": "^17.2.1", "eciesjs": "^0.4.10", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "picomatch": "^4.0.4", "which": "^4.0.0", "yocto-spinner": "^1.1.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-2OUX4KDKvQA6oa7oESG8eNcV4K/2C5jgrbxUcT0VoH9Zelg6dT+rDYew4w2GmXRV3db0tUaM4QZG3MyJL3fU5Q=="],
|
||||
|
||||
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
|
||||
|
|
@ -705,6 +708,8 @@
|
|||
|
||||
"buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
|
||||
|
||||
"buffer-writer": ["buffer-writer@2.0.0", "", {}, "sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="],
|
||||
|
||||
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
|
||||
|
|
@ -751,7 +756,7 @@
|
|||
|
||||
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||
|
||||
"commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
||||
"commander": ["commander@12.0.0", "", {}, "sha512-MwVNWlYjDTtOjX5PiD7o5pK0UrFU/OYgcJfjjK4RaHZETNtjJqrZa9Y9ds88+A+f+d5lv+561eZ+yCKoS3gbAA=="],
|
||||
|
||||
"content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="],
|
||||
|
||||
|
|
@ -765,6 +770,8 @@
|
|||
|
||||
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
|
||||
|
||||
"copy-anything": ["copy-anything@3.0.5", "", { "dependencies": { "is-what": "^4.1.8" } }, "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w=="],
|
||||
|
||||
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
|
||||
|
||||
"cosmiconfig": ["cosmiconfig@9.0.1", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ=="],
|
||||
|
|
@ -1047,6 +1054,8 @@
|
|||
|
||||
"is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="],
|
||||
|
||||
"is-what": ["is-what@4.1.16", "", {}, "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A=="],
|
||||
|
||||
"is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="],
|
||||
|
||||
"isbot": ["isbot@5.1.39", "", {}, "sha512-obH0yYahGXdzNxo+djmHhBYThUKDkz565cxkIlt2L9hXfv1NlaLKoDBHo6KxXsYrIXx2RK3x5vY36CfZcobxEw=="],
|
||||
|
|
@ -1197,6 +1206,8 @@
|
|||
|
||||
"outvariant": ["outvariant@1.4.3", "", {}, "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA=="],
|
||||
|
||||
"packet-reader": ["packet-reader@1.0.0", "", {}, "sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ=="],
|
||||
|
||||
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
|
||||
|
||||
"parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="],
|
||||
|
|
@ -1343,6 +1354,8 @@
|
|||
|
||||
"send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
|
||||
|
||||
"serialize-error": ["serialize-error@8.1.0", "", { "dependencies": { "type-fest": "^0.20.2" } }, "sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ=="],
|
||||
|
||||
"seroval": ["seroval@1.5.2", "", {}, "sha512-xcRN39BdsnO9Tf+VzsE7b3JyTJASItIV1FVFewJKCFcW4s4haIKS3e6vj8PGB9qBwC7tnuOywQMdv5N4qkzi7Q=="],
|
||||
|
||||
"seroval-plugins": ["seroval-plugins@1.5.2", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-qpY0Cl+fKYFn4GOf3cMiq6l72CpuVaawb6ILjubOQ+diJ54LfOWaSSPsaswN8DRPIPW4Yq+tE1k5aKd7ILyaFg=="],
|
||||
|
|
@ -1413,6 +1426,8 @@
|
|||
|
||||
"strtok3": ["strtok3@6.3.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "peek-readable": "^4.1.0" } }, "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw=="],
|
||||
|
||||
"superjson": ["superjson@1.13.3", "", { "dependencies": { "copy-anything": "^3.0.2" } }, "sha512-mJiVjfd2vokfDxsQPOwJ/PtanO87LhpYY88ubI5dUB1Ab58Txbyje3+jpm+/83R/fevaq/107NNhtYBLuoTrFg=="],
|
||||
|
||||
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
|
||||
|
||||
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
|
||||
|
|
@ -1461,7 +1476,7 @@
|
|||
|
||||
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
|
||||
|
||||
"type-fest": ["type-fest@5.6.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA=="],
|
||||
"type-fest": ["type-fest@0.20.2", "", {}, "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ=="],
|
||||
|
||||
"type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
|
||||
|
||||
|
|
@ -1549,6 +1564,8 @@
|
|||
|
||||
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
|
||||
"yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="],
|
||||
|
||||
"yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
|
||||
|
||||
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
|
||||
|
|
@ -1563,6 +1580,8 @@
|
|||
|
||||
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"@dbos-inc/dbos-sdk/pg": ["pg@8.11.3", "", { "dependencies": { "buffer-writer": "2.0.0", "packet-reader": "1.0.0", "pg-connection-string": "^2.6.2", "pg-pool": "^3.6.1", "pg-protocol": "^1.6.0", "pg-types": "^2.1.0", "pgpass": "1.x" }, "optionalDependencies": { "pg-cloudflare": "^1.1.1" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-+9iuvG8QfaaUrrph+kpF24cXkH1YOOUeArRNYIxq1viYHZagBxrTno7cecY1Fa44tJeZvaoG+Djpkc3JwehN5g=="],
|
||||
|
||||
"@dotenvx/dotenvx/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
|
||||
|
||||
"@dotenvx/dotenvx/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
|
||||
|
|
@ -1633,6 +1652,8 @@
|
|||
|
||||
"micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
||||
|
||||
"msw/type-fest": ["type-fest@5.6.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA=="],
|
||||
|
||||
"npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
|
||||
|
||||
"parse5-htmlparser2-tree-adapter/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
|
||||
|
|
@ -1651,6 +1672,8 @@
|
|||
|
||||
"router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
|
||||
|
||||
"shadcn/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
||||
|
||||
"shadcn/postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="],
|
||||
|
||||
"shadcn/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
|
|
|||
22
web/src/components/sync-button.tsx
Normal file
22
web/src/components/sync-button.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { client } from "#/lib/eden";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
export function SyncButton() {
|
||||
return (
|
||||
<Button
|
||||
onClick={async () => {
|
||||
toast.promise(
|
||||
client.api.sync.post().then((data) => console.log(data)),
|
||||
{
|
||||
loading: "Syncing...",
|
||||
success: "Synced!",
|
||||
error: "Sync failed",
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
Sync
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { treaty } from "@elysiajs/eden";
|
||||
import type { App } from "../../../api/src/index";
|
||||
|
||||
export const client = treaty<App>("localhost:3000");
|
||||
export const client = treaty<App>("127.0.0.1:3000", {});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { SyncButton } from "#/components/sync-button";
|
||||
import { UserInfo } from "#/components/user-info";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
|
|
@ -7,6 +8,7 @@ function App() {
|
|||
return (
|
||||
<main>
|
||||
<UserInfo />
|
||||
<SyncButton />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue