format issues + socket
This commit is contained in:
parent
7738645a69
commit
f79a9893a1
16 changed files with 1984 additions and 1654 deletions
6
api/AGENTS.md
Normal file
6
api/AGENTS.md
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
Run biome and typescript checks after your changes:
|
||||
|
||||
```
|
||||
bun x biome ci
|
||||
bun x tsc --noEmit
|
||||
```
|
||||
|
|
@ -1,11 +1,17 @@
|
|||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
|
||||
if (!databaseUrl) {
|
||||
throw new Error("Missing required env var: DATABASE_URL");
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
out: "./drizzle",
|
||||
schema: "./src/db/schema.ts",
|
||||
dialect: "postgresql",
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL!,
|
||||
},
|
||||
schemaFilter: ["public"],
|
||||
out: "./drizzle",
|
||||
schema: "./src/db/schema.ts",
|
||||
dialect: "postgresql",
|
||||
dbCredentials: {
|
||||
url: databaseUrl,
|
||||
},
|
||||
schemaFilter: ["public"],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,23 +1,24 @@
|
|||
{
|
||||
"name": "api",
|
||||
"module": "index.ts",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"dev": "bun run --watch src/index.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"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",
|
||||
"elysia": "catalog:"
|
||||
}
|
||||
"name": "api",
|
||||
"module": "index.ts",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"dev": "bun run --watch src/index.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"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",
|
||||
"elysia": "catalog:"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,59 +1,65 @@
|
|||
import { SpotifyApi } from "@spotify/web-api-ts-sdk";
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import Elysia from "elysia";
|
||||
import { db } from "./db";
|
||||
import Elysia, { status, type Context } from "elysia";
|
||||
import * as schema from "./db/auth-schema";
|
||||
import { SpotifyApi } from "@spotify/web-api-ts-sdk";
|
||||
|
||||
export const SPOTIFY_CLIENT_ID = process.env.SPOTIFY_CLIENT_ID!;
|
||||
export const SPOTIFY_CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET!;
|
||||
const requireEnv = (name: string): string => {
|
||||
const value = process.env[name];
|
||||
if (!value) throw new Error(`Missing required env var: ${name}`);
|
||||
return value;
|
||||
};
|
||||
|
||||
export const SPOTIFY_CLIENT_ID = requireEnv("SPOTIFY_CLIENT_ID");
|
||||
export const SPOTIFY_CLIENT_SECRET = requireEnv("SPOTIFY_CLIENT_SECRET");
|
||||
|
||||
export const defaultSdk = SpotifyApi.withClientCredentials(
|
||||
SPOTIFY_CLIENT_ID,
|
||||
SPOTIFY_CLIENT_SECRET,
|
||||
SPOTIFY_CLIENT_ID,
|
||||
SPOTIFY_CLIENT_SECRET,
|
||||
);
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: drizzleAdapter(db, {
|
||||
provider: "pg",
|
||||
schema,
|
||||
}),
|
||||
socialProviders: {
|
||||
spotify: {
|
||||
clientId: SPOTIFY_CLIENT_ID,
|
||||
clientSecret: SPOTIFY_CLIENT_SECRET,
|
||||
scope: [
|
||||
"user-read-playback-state",
|
||||
"user-read-currently-playing",
|
||||
"user-modify-playback-state",
|
||||
"playlist-read-private",
|
||||
"playlist-read-collaborative",
|
||||
"user-follow-read",
|
||||
"user-top-read",
|
||||
"user-read-recently-played",
|
||||
"user-library-read",
|
||||
// "user-personalized",
|
||||
"user-read-email",
|
||||
],
|
||||
},
|
||||
},
|
||||
database: drizzleAdapter(db, {
|
||||
provider: "pg",
|
||||
schema,
|
||||
}),
|
||||
socialProviders: {
|
||||
spotify: {
|
||||
clientId: SPOTIFY_CLIENT_ID,
|
||||
clientSecret: SPOTIFY_CLIENT_SECRET,
|
||||
scope: [
|
||||
"user-read-playback-state",
|
||||
"user-read-currently-playing",
|
||||
"user-modify-playback-state",
|
||||
"playlist-read-private",
|
||||
"playlist-read-collaborative",
|
||||
"user-follow-read",
|
||||
"user-top-read",
|
||||
"user-read-recently-played",
|
||||
"user-library-read",
|
||||
// "user-personalized",
|
||||
"user-read-email",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const betterAuthElysia = new Elysia({ name: "better-auth" })
|
||||
.mount(auth.handler)
|
||||
.macro({
|
||||
auth: {
|
||||
async resolve({ status, request: { headers } }) {
|
||||
const session = await auth.api.getSession({
|
||||
headers,
|
||||
});
|
||||
.mount(auth.handler)
|
||||
.macro({
|
||||
auth: {
|
||||
async resolve({ status, request: { headers } }) {
|
||||
const session = await auth.api.getSession({
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!session) return status(401);
|
||||
if (!session) return status(401);
|
||||
|
||||
return {
|
||||
user: session.user,
|
||||
session: session.session,
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
return {
|
||||
user: session.user,
|
||||
session: session.session,
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,93 +1,93 @@
|
|||
import { relations } from "drizzle-orm/_relations";
|
||||
import { pgTable, text, timestamp, boolean, index } from "drizzle-orm/pg-core";
|
||||
import { boolean, index, pgTable, text, timestamp } from "drizzle-orm/pg-core";
|
||||
|
||||
export const user = pgTable("user", {
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
email: text("email").notNull().unique(),
|
||||
emailVerified: boolean("email_verified").default(false).notNull(),
|
||||
image: text("image"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.defaultNow()
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
email: text("email").notNull().unique(),
|
||||
emailVerified: boolean("email_verified").default(false).notNull(),
|
||||
image: text("image"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.defaultNow()
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
});
|
||||
|
||||
export const session = pgTable(
|
||||
"session",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
expiresAt: timestamp("expires_at").notNull(),
|
||||
token: text("token").notNull().unique(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
ipAddress: text("ip_address"),
|
||||
userAgent: text("user_agent"),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
},
|
||||
(table) => [index("session_userId_idx").on(table.userId)],
|
||||
"session",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
expiresAt: timestamp("expires_at").notNull(),
|
||||
token: text("token").notNull().unique(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
ipAddress: text("ip_address"),
|
||||
userAgent: text("user_agent"),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
},
|
||||
(table) => [index("session_userId_idx").on(table.userId)],
|
||||
);
|
||||
|
||||
export const account = pgTable(
|
||||
"account",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
accountId: text("account_id").notNull(),
|
||||
providerId: text("provider_id").notNull(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
accessToken: text("access_token"),
|
||||
refreshToken: text("refresh_token"),
|
||||
idToken: text("id_token"),
|
||||
accessTokenExpiresAt: timestamp("access_token_expires_at"),
|
||||
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
|
||||
scope: text("scope"),
|
||||
password: text("password"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [index("account_userId_idx").on(table.userId)],
|
||||
"account",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
accountId: text("account_id").notNull(),
|
||||
providerId: text("provider_id").notNull(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
accessToken: text("access_token"),
|
||||
refreshToken: text("refresh_token"),
|
||||
idToken: text("id_token"),
|
||||
accessTokenExpiresAt: timestamp("access_token_expires_at"),
|
||||
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
|
||||
scope: text("scope"),
|
||||
password: text("password"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [index("account_userId_idx").on(table.userId)],
|
||||
);
|
||||
|
||||
export const verification = pgTable(
|
||||
"verification",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
identifier: text("identifier").notNull(),
|
||||
value: text("value").notNull(),
|
||||
expiresAt: timestamp("expires_at").notNull(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.defaultNow()
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [index("verification_identifier_idx").on(table.identifier)],
|
||||
"verification",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
identifier: text("identifier").notNull(),
|
||||
value: text("value").notNull(),
|
||||
expiresAt: timestamp("expires_at").notNull(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.defaultNow()
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [index("verification_identifier_idx").on(table.identifier)],
|
||||
);
|
||||
|
||||
export const userRelations = relations(user, ({ many }) => ({
|
||||
sessions: many(session),
|
||||
accounts: many(account),
|
||||
sessions: many(session),
|
||||
accounts: many(account),
|
||||
}));
|
||||
|
||||
export const sessionRelations = relations(session, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [session.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
user: one(user, {
|
||||
fields: [session.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const accountRelations = relations(account, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [account.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
user: one(user, {
|
||||
fields: [account.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
import { drizzle } from "drizzle-orm/node-postgres";
|
||||
import { relations } from "./schema";
|
||||
|
||||
export const db = drizzle(process.env.DATABASE_URL!, { relations });
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
|
||||
if (!databaseUrl) {
|
||||
throw new Error("Missing required env var: DATABASE_URL");
|
||||
}
|
||||
|
||||
export const db = drizzle(databaseUrl, { relations });
|
||||
|
|
|
|||
1027
api/src/db/schema.ts
1027
api/src/db/schema.ts
File diff suppressed because it is too large
Load diff
|
|
@ -1,488 +1,504 @@
|
|||
import type {
|
||||
Album,
|
||||
Artist,
|
||||
Image,
|
||||
PlayHistory,
|
||||
SavedAlbum,
|
||||
SavedTrack,
|
||||
SimplifiedAlbum,
|
||||
SimplifiedArtist,
|
||||
Track,
|
||||
Album,
|
||||
Artist,
|
||||
Image,
|
||||
PlayHistory,
|
||||
SavedAlbum,
|
||||
SavedTrack,
|
||||
SimplifiedAlbum,
|
||||
SimplifiedArtist,
|
||||
Track,
|
||||
} from "@spotify/web-api-ts-sdk";
|
||||
import { db } from ".";
|
||||
import {
|
||||
album,
|
||||
albumArtist,
|
||||
albumGenre,
|
||||
albumImage,
|
||||
artist,
|
||||
artistGenre,
|
||||
artistImage,
|
||||
followedArtist,
|
||||
genre,
|
||||
playbackHistory,
|
||||
platformImage,
|
||||
savedAlbum,
|
||||
savedTrack,
|
||||
topArtist,
|
||||
topTrack,
|
||||
track,
|
||||
trackArtist,
|
||||
} from "./schema";
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import { defaultSdk } from "../auth";
|
||||
import { db } from ".";
|
||||
import {
|
||||
album,
|
||||
albumArtist,
|
||||
albumGenre,
|
||||
albumImage,
|
||||
artist,
|
||||
artistGenre,
|
||||
artistImage,
|
||||
followedArtist,
|
||||
genre,
|
||||
platformImage,
|
||||
playbackHistory,
|
||||
savedAlbum,
|
||||
savedTrack,
|
||||
topArtist,
|
||||
topTrack,
|
||||
track,
|
||||
trackArtist,
|
||||
} from "./schema";
|
||||
|
||||
export const PLATFORM_SPOTIFY = "spotify" as const;
|
||||
|
||||
type DbClient = typeof db;
|
||||
type DbTransaction = Parameters<typeof db.transaction>[0] extends (
|
||||
tx: infer T,
|
||||
) => Promise<any>
|
||||
? T
|
||||
: never;
|
||||
tx: infer T,
|
||||
) => Promise<unknown>
|
||||
? T
|
||||
: never;
|
||||
type DbLike = DbClient | DbTransaction;
|
||||
|
||||
const requireMapEntry = <K, V>(map: Map<K, V>, key: K, label: string): V => {
|
||||
const value = map.get(key);
|
||||
if (!value) throw new Error(`Missing ${label} for ${String(key)}`);
|
||||
return value;
|
||||
};
|
||||
|
||||
export async function upsertImages(images: Image[], dbClient: DbLike = db) {
|
||||
await dbClient
|
||||
.insert(platformImage)
|
||||
.values(
|
||||
images.map(({ url, height, width }) => ({
|
||||
platform: PLATFORM_SPOTIFY,
|
||||
url,
|
||||
height,
|
||||
width,
|
||||
})),
|
||||
)
|
||||
.onConflictDoNothing();
|
||||
await dbClient
|
||||
.insert(platformImage)
|
||||
.values(
|
||||
images.map(({ url, height, width }) => ({
|
||||
platform: PLATFORM_SPOTIFY,
|
||||
url,
|
||||
height,
|
||||
width,
|
||||
})),
|
||||
)
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
export async function upsertGenres(genres: string[], dbClient: DbLike = db) {
|
||||
const values = genres.filter(Boolean).map((name) => ({ name }));
|
||||
if (values.length === 0) return;
|
||||
await dbClient.insert(genre).values(values).onConflictDoNothing();
|
||||
const values = genres.filter(Boolean).map((name) => ({ name }));
|
||||
if (values.length === 0) return;
|
||||
await dbClient.insert(genre).values(values).onConflictDoNothing();
|
||||
}
|
||||
|
||||
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,
|
||||
name,
|
||||
popularity,
|
||||
type,
|
||||
})),
|
||||
)
|
||||
.onConflictDoNothing();
|
||||
await upsertImages(
|
||||
artists.flatMap((a) => a.images),
|
||||
dbClient,
|
||||
);
|
||||
await upsertGenres(
|
||||
artists.flatMap((a) => a.genres),
|
||||
dbClient,
|
||||
);
|
||||
for (const spotifyArtist of artists) {
|
||||
await dbClient
|
||||
.insert(artistImage)
|
||||
.select(
|
||||
dbClient
|
||||
.select({
|
||||
artistId: artist.id,
|
||||
imageId: platformImage.id,
|
||||
})
|
||||
.from(platformImage)
|
||||
.where(
|
||||
and(
|
||||
eq(platformImage.platform, PLATFORM_SPOTIFY),
|
||||
inArray(
|
||||
platformImage.url,
|
||||
spotifyArtist.images.map((t) => t.url),
|
||||
),
|
||||
),
|
||||
)
|
||||
.innerJoin(artist, eq(artist.platform_id, spotifyArtist.id)),
|
||||
)
|
||||
.onConflictDoNothing();
|
||||
await dbClient
|
||||
.insert(artistGenre)
|
||||
.select(
|
||||
dbClient
|
||||
.select({
|
||||
artistId: artist.id,
|
||||
genreId: genre.id,
|
||||
})
|
||||
.from(genre)
|
||||
.where(inArray(genre.name, spotifyArtist.genres))
|
||||
.innerJoin(artist, eq(artist.platform_id, spotifyArtist.id)),
|
||||
)
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
await dbClient
|
||||
.insert(artist)
|
||||
.values(
|
||||
artists.map(({ id, name, popularity, type }) => ({
|
||||
platform: PLATFORM_SPOTIFY,
|
||||
platform_id: id,
|
||||
name,
|
||||
popularity,
|
||||
type,
|
||||
})),
|
||||
)
|
||||
.onConflictDoNothing();
|
||||
await upsertImages(
|
||||
artists.flatMap((a) => a.images),
|
||||
dbClient,
|
||||
);
|
||||
await upsertGenres(
|
||||
artists.flatMap((a) => a.genres),
|
||||
dbClient,
|
||||
);
|
||||
for (const spotifyArtist of artists) {
|
||||
await dbClient
|
||||
.insert(artistImage)
|
||||
.select(
|
||||
dbClient
|
||||
.select({
|
||||
artistId: artist.id,
|
||||
imageId: platformImage.id,
|
||||
})
|
||||
.from(platformImage)
|
||||
.where(
|
||||
and(
|
||||
eq(platformImage.platform, PLATFORM_SPOTIFY),
|
||||
inArray(
|
||||
platformImage.url,
|
||||
spotifyArtist.images.map((t) => t.url),
|
||||
),
|
||||
),
|
||||
)
|
||||
.innerJoin(artist, eq(artist.platform_id, spotifyArtist.id)),
|
||||
)
|
||||
.onConflictDoNothing();
|
||||
await dbClient
|
||||
.insert(artistGenre)
|
||||
.select(
|
||||
dbClient
|
||||
.select({
|
||||
artistId: artist.id,
|
||||
genreId: genre.id,
|
||||
})
|
||||
.from(genre)
|
||||
.where(inArray(genre.name, spotifyArtist.genres))
|
||||
.innerJoin(artist, eq(artist.platform_id, spotifyArtist.id)),
|
||||
)
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
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[],
|
||||
dbClient: DbLike = db,
|
||||
artistIds: string[],
|
||||
dbClient: DbLike = db,
|
||||
) {
|
||||
const missingArtistIds = await getMissingArtists(artistIds, dbClient);
|
||||
if (missingArtistIds.length === 0) return [];
|
||||
let missingArtists: Artist[] = [];
|
||||
for (let i = 0; i < missingArtistIds.length / 50; i++) {
|
||||
missingArtists.push(
|
||||
...(await defaultSdk.artists.get(
|
||||
missingArtistIds.slice(i * 50, (i + 1) * 50),
|
||||
)),
|
||||
);
|
||||
}
|
||||
await upsertArtists(missingArtists, dbClient);
|
||||
return missingArtists;
|
||||
const missingArtistIds = await getMissingArtists(artistIds, dbClient);
|
||||
if (missingArtistIds.length === 0) return [];
|
||||
const missingArtists: Artist[] = [];
|
||||
for (let i = 0; i < missingArtistIds.length / 50; i++) {
|
||||
missingArtists.push(
|
||||
...(await defaultSdk.artists.get(
|
||||
missingArtistIds.slice(i * 50, (i + 1) * 50),
|
||||
)),
|
||||
);
|
||||
}
|
||||
await upsertArtists(missingArtists, dbClient);
|
||||
return missingArtists;
|
||||
}
|
||||
|
||||
function isFullArtistArray(
|
||||
artists: Artist[] | SimplifiedArtist[],
|
||||
artists: Artist[] | SimplifiedArtist[],
|
||||
): artists is Artist[] {
|
||||
return "images" in artists[0]!;
|
||||
const firstArtist = artists[0];
|
||||
if (!firstArtist) return false;
|
||||
return "images" in firstArtist;
|
||||
}
|
||||
|
||||
async function upsertMissingArtists(
|
||||
artists: SimplifiedArtist[] | Artist[],
|
||||
dbClient: DbLike = db,
|
||||
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),
|
||||
dbClient,
|
||||
);
|
||||
if (missingArtistIds.length === 0) return;
|
||||
missingArtists = artists.filter((a) => missingArtistIds.includes(a.id));
|
||||
} else {
|
||||
missingArtists = await lookupMissingArtists(
|
||||
artists.map((t) => t.id),
|
||||
dbClient,
|
||||
);
|
||||
}
|
||||
if (missingArtists.length === 0) return;
|
||||
await upsertArtists(missingArtists, dbClient);
|
||||
return missingArtists;
|
||||
if (artists.length === 0) return;
|
||||
let missingArtists: Artist[];
|
||||
if (isFullArtistArray(artists)) {
|
||||
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),
|
||||
dbClient,
|
||||
);
|
||||
}
|
||||
if (missingArtists.length === 0) return;
|
||||
await upsertArtists(missingArtists, dbClient);
|
||||
return missingArtists;
|
||||
}
|
||||
|
||||
async function getArtistIdMap(artistIds: string[], dbClient: DbLike = db) {
|
||||
if (artistIds.length === 0) return new Map<string, string>();
|
||||
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]));
|
||||
if (artistIds.length === 0) return new Map<string, string>();
|
||||
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[], dbClient: DbLike = db) {
|
||||
if (albumIds.length === 0) return new Map<string, string>();
|
||||
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]));
|
||||
if (albumIds.length === 0) return new Map<string, string>();
|
||||
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
|
||||
.filter((row) => row.platform_id)
|
||||
.map((row) => [row.platform_id as string, row.id]),
|
||||
);
|
||||
}
|
||||
|
||||
async function getTrackIdMap(trackIds: string[], dbClient: DbLike = db) {
|
||||
if (trackIds.length === 0) return new Map<string, string>();
|
||||
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]));
|
||||
if (trackIds.length === 0) return new Map<string, string>();
|
||||
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
|
||||
.filter((row) => row.platform_id)
|
||||
.map((row) => [row.platform_id as string, row.id]),
|
||||
);
|
||||
}
|
||||
|
||||
export async function upsertAlbums(
|
||||
albums: Album[] | SimplifiedAlbum[],
|
||||
dbClient: DbLike = db,
|
||||
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,
|
||||
name,
|
||||
type,
|
||||
popularity,
|
||||
release_date: new Date(release_date),
|
||||
label,
|
||||
})),
|
||||
)
|
||||
.onConflictDoNothing();
|
||||
await upsertImages(
|
||||
albums.flatMap((a) => a.images),
|
||||
dbClient,
|
||||
);
|
||||
await upsertGenres(
|
||||
albums.flatMap((a) => a.genres),
|
||||
dbClient,
|
||||
);
|
||||
for (const spotifyAlbum of albums) {
|
||||
await dbClient
|
||||
.insert(albumImage)
|
||||
.select(
|
||||
dbClient
|
||||
.select({
|
||||
albumId: album.id,
|
||||
imageId: platformImage.id,
|
||||
})
|
||||
.from(platformImage)
|
||||
.where(
|
||||
and(
|
||||
eq(platformImage.platform, PLATFORM_SPOTIFY),
|
||||
inArray(
|
||||
platformImage.url,
|
||||
spotifyAlbum.images.map((t) => t.url),
|
||||
),
|
||||
),
|
||||
)
|
||||
.innerJoin(album, eq(album.platform_id, spotifyAlbum.id)),
|
||||
)
|
||||
.onConflictDoNothing();
|
||||
await dbClient
|
||||
.insert(albumArtist)
|
||||
.select(
|
||||
dbClient
|
||||
.select({
|
||||
albumId: album.id,
|
||||
artistId: artist.id,
|
||||
})
|
||||
.from(artist)
|
||||
.where(
|
||||
inArray(
|
||||
artist.platform_id,
|
||||
spotifyAlbum.artists.map((t) => t.id),
|
||||
),
|
||||
)
|
||||
.innerJoin(album, eq(album.platform_id, spotifyAlbum.id)),
|
||||
)
|
||||
.onConflictDoNothing();
|
||||
if (spotifyAlbum.genres?.length > 0)
|
||||
await dbClient
|
||||
.insert(albumGenre)
|
||||
.select(
|
||||
dbClient
|
||||
.select({
|
||||
albumId: album.id,
|
||||
genreId: genre.id,
|
||||
})
|
||||
.from(genre)
|
||||
.where(inArray(genre.name, sql`${spotifyAlbum.genres}`))
|
||||
.innerJoin(album, eq(album.platform_id, spotifyAlbum.id)),
|
||||
)
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
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,
|
||||
name,
|
||||
type,
|
||||
popularity,
|
||||
release_date: new Date(release_date),
|
||||
label,
|
||||
})),
|
||||
)
|
||||
.onConflictDoNothing();
|
||||
await upsertImages(
|
||||
albums.flatMap((a) => a.images),
|
||||
dbClient,
|
||||
);
|
||||
await upsertGenres(
|
||||
albums.flatMap((a) => a.genres),
|
||||
dbClient,
|
||||
);
|
||||
for (const spotifyAlbum of albums) {
|
||||
await dbClient
|
||||
.insert(albumImage)
|
||||
.select(
|
||||
dbClient
|
||||
.select({
|
||||
albumId: album.id,
|
||||
imageId: platformImage.id,
|
||||
})
|
||||
.from(platformImage)
|
||||
.where(
|
||||
and(
|
||||
eq(platformImage.platform, PLATFORM_SPOTIFY),
|
||||
inArray(
|
||||
platformImage.url,
|
||||
spotifyAlbum.images.map((t) => t.url),
|
||||
),
|
||||
),
|
||||
)
|
||||
.innerJoin(album, eq(album.platform_id, spotifyAlbum.id)),
|
||||
)
|
||||
.onConflictDoNothing();
|
||||
await dbClient
|
||||
.insert(albumArtist)
|
||||
.select(
|
||||
dbClient
|
||||
.select({
|
||||
albumId: album.id,
|
||||
artistId: artist.id,
|
||||
})
|
||||
.from(artist)
|
||||
.where(
|
||||
inArray(
|
||||
artist.platform_id,
|
||||
spotifyAlbum.artists.map((t) => t.id),
|
||||
),
|
||||
)
|
||||
.innerJoin(album, eq(album.platform_id, spotifyAlbum.id)),
|
||||
)
|
||||
.onConflictDoNothing();
|
||||
if (spotifyAlbum.genres?.length > 0)
|
||||
await dbClient
|
||||
.insert(albumGenre)
|
||||
.select(
|
||||
dbClient
|
||||
.select({
|
||||
albumId: album.id,
|
||||
genreId: genre.id,
|
||||
})
|
||||
.from(genre)
|
||||
.where(inArray(genre.name, sql`${spotifyAlbum.genres}`))
|
||||
.innerJoin(album, eq(album.platform_id, spotifyAlbum.id)),
|
||||
)
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
}
|
||||
|
||||
export async function upsertTracks(tracks: Track[], dbClient: DbLike = db) {
|
||||
if (tracks.length === 0) return;
|
||||
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) => ({
|
||||
albumId: albumIdMap.get(spotifyTrack.album.id)!,
|
||||
name: spotifyTrack.name,
|
||||
platform: PLATFORM_SPOTIFY,
|
||||
platform_id: spotifyTrack.id,
|
||||
popularity: spotifyTrack.popularity,
|
||||
duration: spotifyTrack.duration_ms,
|
||||
explicit: spotifyTrack.explicit,
|
||||
disc_number: spotifyTrack.disc_number,
|
||||
track_number: spotifyTrack.track_number,
|
||||
})),
|
||||
)
|
||||
.onConflictDoNothing();
|
||||
for (const spotifyTrack of tracks) {
|
||||
await dbClient
|
||||
.insert(trackArtist)
|
||||
.select(
|
||||
dbClient
|
||||
.select({
|
||||
trackId: track.id,
|
||||
artistId: artist.id,
|
||||
})
|
||||
.from(artist)
|
||||
.where(
|
||||
inArray(
|
||||
artist.platform_id,
|
||||
spotifyTrack.artists.map((t) => t.id),
|
||||
),
|
||||
)
|
||||
.innerJoin(track, eq(track.platform_id, spotifyTrack.id)),
|
||||
)
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
if (tracks.length === 0) return;
|
||||
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) => ({
|
||||
albumId: requireMapEntry(albumIdMap, spotifyTrack.album.id, "albumId"),
|
||||
name: spotifyTrack.name,
|
||||
platform: PLATFORM_SPOTIFY,
|
||||
platform_id: spotifyTrack.id,
|
||||
popularity: spotifyTrack.popularity,
|
||||
duration: spotifyTrack.duration_ms,
|
||||
explicit: spotifyTrack.explicit,
|
||||
disc_number: spotifyTrack.disc_number,
|
||||
track_number: spotifyTrack.track_number,
|
||||
})),
|
||||
)
|
||||
.onConflictDoNothing();
|
||||
for (const spotifyTrack of tracks) {
|
||||
await dbClient
|
||||
.insert(trackArtist)
|
||||
.select(
|
||||
dbClient
|
||||
.select({
|
||||
trackId: track.id,
|
||||
artistId: artist.id,
|
||||
})
|
||||
.from(artist)
|
||||
.where(
|
||||
inArray(
|
||||
artist.platform_id,
|
||||
spotifyTrack.artists.map((t) => t.id),
|
||||
),
|
||||
)
|
||||
.innerJoin(track, eq(track.platform_id, spotifyTrack.id)),
|
||||
)
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
}
|
||||
|
||||
export async function upsertTopArtists(
|
||||
userId: string,
|
||||
timeline: "short_term" | "medium_term" | "long_term",
|
||||
artists: Artist[],
|
||||
dbClient: DbLike = db,
|
||||
userId: string,
|
||||
timeline: "short_term" | "medium_term" | "long_term",
|
||||
artists: Artist[],
|
||||
dbClient: DbLike = db,
|
||||
) {
|
||||
if (artists.length === 0) return;
|
||||
await upsertArtists(artists, dbClient);
|
||||
const artistIdMap = await getArtistIdMap(
|
||||
artists.map((t) => t.id),
|
||||
dbClient,
|
||||
);
|
||||
await dbClient
|
||||
.insert(topArtist)
|
||||
.values(
|
||||
artists.map((spotifyArtist, index) => ({
|
||||
artistId: artistIdMap.get(spotifyArtist.id)!,
|
||||
position: index + 1,
|
||||
userId,
|
||||
timeline,
|
||||
})),
|
||||
)
|
||||
.onConflictDoNothing();
|
||||
if (artists.length === 0) return;
|
||||
await upsertArtists(artists, dbClient);
|
||||
const artistIdMap = await getArtistIdMap(
|
||||
artists.map((t) => t.id),
|
||||
dbClient,
|
||||
);
|
||||
await dbClient
|
||||
.insert(topArtist)
|
||||
.values(
|
||||
artists.map((spotifyArtist, index) => ({
|
||||
artistId: requireMapEntry(artistIdMap, spotifyArtist.id, "artistId"),
|
||||
position: index + 1,
|
||||
userId,
|
||||
timeline,
|
||||
})),
|
||||
)
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
export async function upsertTopTracks(
|
||||
userId: string,
|
||||
timeline: "short_term" | "medium_term" | "long_term",
|
||||
tracks: Track[],
|
||||
dbClient: DbLike = db,
|
||||
userId: string,
|
||||
timeline: "short_term" | "medium_term" | "long_term",
|
||||
tracks: Track[],
|
||||
dbClient: DbLike = db,
|
||||
) {
|
||||
if (tracks.length === 0) return;
|
||||
await upsertTracks(tracks, dbClient);
|
||||
const trackIdMap = await getTrackIdMap(
|
||||
tracks.map((t) => t.id),
|
||||
dbClient,
|
||||
);
|
||||
await dbClient
|
||||
.insert(topTrack)
|
||||
.values(
|
||||
tracks.map((spotifyTrack, index) => ({
|
||||
trackId: trackIdMap.get(spotifyTrack.id)!,
|
||||
position: index + 1,
|
||||
userId,
|
||||
timeline,
|
||||
})),
|
||||
)
|
||||
.onConflictDoNothing();
|
||||
if (tracks.length === 0) return;
|
||||
await upsertTracks(tracks, dbClient);
|
||||
const trackIdMap = await getTrackIdMap(
|
||||
tracks.map((t) => t.id),
|
||||
dbClient,
|
||||
);
|
||||
await dbClient
|
||||
.insert(topTrack)
|
||||
.values(
|
||||
tracks.map((spotifyTrack, index) => ({
|
||||
trackId: requireMapEntry(trackIdMap, spotifyTrack.id, "trackId"),
|
||||
position: index + 1,
|
||||
userId,
|
||||
timeline,
|
||||
})),
|
||||
)
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
export async function upsertSavedAlbums(
|
||||
userId: string,
|
||||
saved: SavedAlbum[],
|
||||
dbClient: DbLike = db,
|
||||
userId: string,
|
||||
saved: SavedAlbum[],
|
||||
dbClient: DbLike = db,
|
||||
) {
|
||||
if (saved.length === 0) return;
|
||||
const albums = saved.map((item) => item.album);
|
||||
await upsertAlbums(albums, dbClient);
|
||||
const albumIdMap = await getAlbumIdMap(
|
||||
albums.map((t) => t.id),
|
||||
dbClient,
|
||||
);
|
||||
await dbClient
|
||||
.insert(savedAlbum)
|
||||
.values(
|
||||
saved.map((item) => ({
|
||||
albumId: albumIdMap.get(item.album.id)!,
|
||||
userId,
|
||||
saved_at: new Date(item.added_at),
|
||||
})),
|
||||
)
|
||||
.onConflictDoNothing();
|
||||
if (saved.length === 0) return;
|
||||
const albums = saved.map((item) => item.album);
|
||||
await upsertAlbums(albums, dbClient);
|
||||
const albumIdMap = await getAlbumIdMap(
|
||||
albums.map((t) => t.id),
|
||||
dbClient,
|
||||
);
|
||||
await dbClient
|
||||
.insert(savedAlbum)
|
||||
.values(
|
||||
saved.map((item) => ({
|
||||
albumId: requireMapEntry(albumIdMap, item.album.id, "albumId"),
|
||||
userId,
|
||||
saved_at: new Date(item.added_at),
|
||||
})),
|
||||
)
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
export async function upsertSavedTracks(
|
||||
userId: string,
|
||||
saved: SavedTrack[],
|
||||
dbClient: DbLike = db,
|
||||
userId: string,
|
||||
saved: SavedTrack[],
|
||||
dbClient: DbLike = db,
|
||||
) {
|
||||
if (saved.length === 0) return;
|
||||
const tracks = saved.map((item) => item.track);
|
||||
await upsertTracks(tracks, dbClient);
|
||||
const trackIdMap = await getTrackIdMap(
|
||||
tracks.map((t) => t.id),
|
||||
dbClient,
|
||||
);
|
||||
await dbClient
|
||||
.insert(savedTrack)
|
||||
.values(
|
||||
saved.map((item) => ({
|
||||
trackId: trackIdMap.get(item.track.id)!,
|
||||
userId,
|
||||
saved_at: new Date(item.added_at),
|
||||
})),
|
||||
)
|
||||
.onConflictDoNothing();
|
||||
if (saved.length === 0) return;
|
||||
const tracks = saved.map((item) => item.track);
|
||||
await upsertTracks(tracks, dbClient);
|
||||
const trackIdMap = await getTrackIdMap(
|
||||
tracks.map((t) => t.id),
|
||||
dbClient,
|
||||
);
|
||||
await dbClient
|
||||
.insert(savedTrack)
|
||||
.values(
|
||||
saved.map((item) => ({
|
||||
trackId: requireMapEntry(trackIdMap, item.track.id, "trackId"),
|
||||
userId,
|
||||
saved_at: new Date(item.added_at),
|
||||
})),
|
||||
)
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
export async function upsertFollowedArtists(
|
||||
userId: string,
|
||||
artists: Artist[],
|
||||
dbClient: DbLike = db,
|
||||
userId: string,
|
||||
artists: Artist[],
|
||||
dbClient: DbLike = db,
|
||||
) {
|
||||
if (artists.length === 0) return;
|
||||
await upsertArtists(artists, dbClient);
|
||||
const artistIdMap = await getArtistIdMap(
|
||||
artists.map((t) => t.id),
|
||||
dbClient,
|
||||
);
|
||||
await dbClient
|
||||
.insert(followedArtist)
|
||||
.values(
|
||||
artists.map((spotifyArtist) => ({
|
||||
artistId: artistIdMap.get(spotifyArtist.id)!,
|
||||
userId,
|
||||
})),
|
||||
)
|
||||
.onConflictDoNothing();
|
||||
if (artists.length === 0) return;
|
||||
await upsertArtists(artists, dbClient);
|
||||
const artistIdMap = await getArtistIdMap(
|
||||
artists.map((t) => t.id),
|
||||
dbClient,
|
||||
);
|
||||
await dbClient
|
||||
.insert(followedArtist)
|
||||
.values(
|
||||
artists.map((spotifyArtist) => ({
|
||||
artistId: requireMapEntry(artistIdMap, spotifyArtist.id, "artistId"),
|
||||
userId,
|
||||
})),
|
||||
)
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
export async function upsertPlaybackHistory(
|
||||
userId: string,
|
||||
items: PlayHistory[],
|
||||
dbClient: DbLike = db,
|
||||
userId: string,
|
||||
items: PlayHistory[],
|
||||
dbClient: DbLike = db,
|
||||
) {
|
||||
if (items.length === 0) return;
|
||||
const tracks = items.map((item) => item.track);
|
||||
await upsertTracks(tracks, dbClient);
|
||||
const trackIdMap = await getTrackIdMap(
|
||||
tracks.map((t) => t.id),
|
||||
dbClient,
|
||||
);
|
||||
await dbClient
|
||||
.insert(playbackHistory)
|
||||
.values(
|
||||
items.map((item) => ({
|
||||
trackId: trackIdMap.get(item.track.id)!,
|
||||
userId,
|
||||
played_at: new Date(item.played_at),
|
||||
})),
|
||||
)
|
||||
.onConflictDoNothing();
|
||||
if (items.length === 0) return;
|
||||
const tracks = items.map((item) => item.track);
|
||||
await upsertTracks(tracks, dbClient);
|
||||
const trackIdMap = await getTrackIdMap(
|
||||
tracks.map((t) => t.id),
|
||||
dbClient,
|
||||
);
|
||||
await dbClient
|
||||
.insert(playbackHistory)
|
||||
.values(
|
||||
items.map((item) => ({
|
||||
trackId: requireMapEntry(trackIdMap, item.track.id, "trackId"),
|
||||
userId,
|
||||
played_at: new Date(item.played_at),
|
||||
})),
|
||||
)
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,6 @@ import { DBOS } from "@dbos-inc/dbos-sdk";
|
|||
import "./workflows/sync";
|
||||
|
||||
DBOS.setConfig({
|
||||
name: "itpdp",
|
||||
systemDatabaseUrl: process.env.DATABASE_URL,
|
||||
name: "itpdp",
|
||||
systemDatabaseUrl: process.env.DATABASE_URL,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,19 +1,19 @@
|
|||
import { Elysia, t } from "elysia";
|
||||
import { DBOS } from "@dbos-inc/dbos-sdk";
|
||||
import { Elysia } from "elysia";
|
||||
import { betterAuthElysia } from "./auth";
|
||||
import { syncApp } from "./routes/sync";
|
||||
import { DBOS } from "@dbos-inc/dbos-sdk";
|
||||
import "./workflows/sync";
|
||||
import "./dbos.ts";
|
||||
import { statsApp } from "./routes/stats.ts";
|
||||
import { partyApp } from "./routes/party";
|
||||
import { statsApp } from "./routes/stats.ts";
|
||||
|
||||
const app = new Elysia()
|
||||
.use(betterAuthElysia)
|
||||
.group("/api", (app) => app.use(syncApp).use(statsApp).use(partyApp))
|
||||
.listen(4000);
|
||||
.use(betterAuthElysia)
|
||||
.group("/api", (app) => app.use(syncApp).use(statsApp).use(partyApp))
|
||||
.listen(4000);
|
||||
|
||||
export type App = typeof app;
|
||||
|
||||
await DBOS.launch({
|
||||
conductorKey: process.env.DBOS_CONDUCTOR_KEY,
|
||||
conductorKey: process.env.DBOS_CONDUCTOR_KEY,
|
||||
});
|
||||
|
|
|
|||
85
api/src/party-sockets.ts
Normal file
85
api/src/party-sockets.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
type PartySocketEvent = {
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type WebSocketLike = {
|
||||
send: (data: string) => void;
|
||||
close?: (code?: number, reason?: string) => void;
|
||||
};
|
||||
|
||||
const partySockets = new Map<string, Map<string, Set<WebSocketLike>>>();
|
||||
|
||||
function getPartyUserSockets(partyId: string, userId: string) {
|
||||
const partyMap = partySockets.get(partyId);
|
||||
if (!partyMap) return null;
|
||||
return partyMap.get(userId) ?? null;
|
||||
}
|
||||
|
||||
export function registerPartySocket(
|
||||
partyId: string,
|
||||
userId: string,
|
||||
ws: WebSocketLike,
|
||||
) {
|
||||
let partyMap = partySockets.get(partyId);
|
||||
if (!partyMap) {
|
||||
partyMap = new Map();
|
||||
partySockets.set(partyId, partyMap);
|
||||
}
|
||||
|
||||
let userSockets = partyMap.get(userId);
|
||||
if (!userSockets) {
|
||||
userSockets = new Set();
|
||||
partyMap.set(userId, userSockets);
|
||||
}
|
||||
|
||||
userSockets.add(ws);
|
||||
}
|
||||
|
||||
export function unregisterPartySocket(
|
||||
partyId: string,
|
||||
userId: string,
|
||||
ws: WebSocketLike,
|
||||
) {
|
||||
const partyMap = partySockets.get(partyId);
|
||||
if (!partyMap) return;
|
||||
|
||||
const userSockets = partyMap.get(userId);
|
||||
if (!userSockets) return;
|
||||
|
||||
userSockets.delete(ws);
|
||||
|
||||
if (userSockets.size === 0) {
|
||||
partyMap.delete(userId);
|
||||
}
|
||||
|
||||
if (partyMap.size === 0) {
|
||||
partySockets.delete(partyId);
|
||||
}
|
||||
}
|
||||
|
||||
export function broadcastPartyEvent(partyId: string, event: PartySocketEvent) {
|
||||
const partyMap = partySockets.get(partyId);
|
||||
if (!partyMap) return;
|
||||
|
||||
const payload = JSON.stringify(event);
|
||||
for (const userSockets of partyMap.values()) {
|
||||
for (const ws of userSockets) {
|
||||
ws.send(payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function sendPartyEventToUser(
|
||||
partyId: string,
|
||||
userId: string,
|
||||
event: PartySocketEvent,
|
||||
) {
|
||||
const userSockets = getPartyUserSockets(partyId, userId);
|
||||
if (!userSockets) return;
|
||||
|
||||
const payload = JSON.stringify(event);
|
||||
for (const ws of userSockets) {
|
||||
ws.send(payload);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,14 @@
|
|||
import Elysia, { t } from "elysia";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { betterAuthElysia } from "../auth";
|
||||
import Elysia, { t } from "elysia";
|
||||
import { auth, betterAuthElysia } from "../auth";
|
||||
import { db } from "../db";
|
||||
import { party, partyMember } from "../db/schema";
|
||||
import {
|
||||
broadcastPartyEvent,
|
||||
registerPartySocket,
|
||||
sendPartyEventToUser,
|
||||
unregisterPartySocket,
|
||||
} from "../party-sockets";
|
||||
|
||||
const PARTY_STATUS = ["created", "started", "ended"] as const;
|
||||
|
||||
|
|
@ -10,289 +16,485 @@ type PartyStatus = (typeof PARTY_STATUS)[number];
|
|||
|
||||
type DbClient = typeof db;
|
||||
type DbTransaction = Parameters<typeof db.transaction>[0] extends (
|
||||
tx: infer T,
|
||||
) => Promise<any>
|
||||
? T
|
||||
: never;
|
||||
tx: infer T,
|
||||
) => Promise<unknown>
|
||||
? T
|
||||
: never;
|
||||
type DbLike = DbClient | DbTransaction;
|
||||
|
||||
type PartySnapshot = NonNullable<Awaited<ReturnType<typeof getPartyStatus>>>;
|
||||
|
||||
type PartySocketMessage =
|
||||
| {
|
||||
type: "member_payload";
|
||||
payload: unknown;
|
||||
}
|
||||
| {
|
||||
type: "ping";
|
||||
};
|
||||
|
||||
const MAX_MEMBER_PAYLOAD_SIZE = 8_000;
|
||||
|
||||
type PartyWsData = {
|
||||
user?: { id: string };
|
||||
partyId?: string;
|
||||
};
|
||||
|
||||
async function getPartyForUser(userId: string) {
|
||||
const memberships = await db.query.partyMember.findMany({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
with: {
|
||||
party: true,
|
||||
},
|
||||
limit: 1,
|
||||
});
|
||||
return memberships[0]?.party ?? null;
|
||||
const memberships = await db.query.partyMember.findMany({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
with: {
|
||||
party: true,
|
||||
},
|
||||
limit: 1,
|
||||
});
|
||||
return memberships[0]?.party ?? null;
|
||||
}
|
||||
|
||||
async function getMemberRecord(dbClient: DbLike, userId: string) {
|
||||
return (
|
||||
(await dbClient.query.partyMember.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
})) ?? null
|
||||
);
|
||||
return (
|
||||
(await dbClient.query.partyMember.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
})) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
async function getPartyStatus(partyId: string) {
|
||||
const party = await db.query.party.findFirst({
|
||||
where: {
|
||||
id: partyId,
|
||||
},
|
||||
});
|
||||
if (!party) return null;
|
||||
const members = await db.query.partyMember.findMany({
|
||||
where: {
|
||||
partyId,
|
||||
},
|
||||
with: {
|
||||
user: true,
|
||||
},
|
||||
orderBy: {
|
||||
joinedAt: "asc",
|
||||
},
|
||||
});
|
||||
return {
|
||||
party,
|
||||
members,
|
||||
};
|
||||
const party = await db.query.party.findFirst({
|
||||
where: {
|
||||
id: partyId,
|
||||
},
|
||||
});
|
||||
if (!party) return null;
|
||||
const members = await db.query.partyMember.findMany({
|
||||
where: {
|
||||
partyId,
|
||||
},
|
||||
with: {
|
||||
user: true,
|
||||
},
|
||||
orderBy: {
|
||||
joinedAt: "asc",
|
||||
},
|
||||
});
|
||||
return {
|
||||
party,
|
||||
members,
|
||||
};
|
||||
}
|
||||
|
||||
function broadcastSnapshot(partyId: string, snapshot: PartySnapshot | null) {
|
||||
if (!snapshot) return;
|
||||
broadcastPartyEvent(partyId, {
|
||||
type: "party_status",
|
||||
party: snapshot.party,
|
||||
members: snapshot.members,
|
||||
});
|
||||
}
|
||||
|
||||
function getPayloadSize(payload: unknown) {
|
||||
try {
|
||||
return JSON.stringify(payload).length;
|
||||
} catch {
|
||||
return Infinity;
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupPartyIfEmpty(dbClient: DbLike, partyId: string) {
|
||||
const members = await dbClient.query.partyMember.findMany({
|
||||
where: {
|
||||
partyId,
|
||||
},
|
||||
limit: 1,
|
||||
});
|
||||
if (members.length > 0) return;
|
||||
await dbClient.delete(party).where(eq(party.id, partyId));
|
||||
const members = await dbClient.query.partyMember.findMany({
|
||||
where: {
|
||||
partyId,
|
||||
},
|
||||
limit: 1,
|
||||
});
|
||||
if (members.length > 0) return;
|
||||
await dbClient.delete(party).where(eq(party.id, partyId));
|
||||
}
|
||||
|
||||
async function leaveParty(dbClient: DbLike, userId: string) {
|
||||
const member = await getMemberRecord(dbClient, userId);
|
||||
if (!member) return null;
|
||||
await dbClient.delete(partyMember).where(eq(partyMember.id, member.id));
|
||||
const nextHost = await dbClient.query.partyMember.findFirst({
|
||||
where: {
|
||||
partyId: member.partyId,
|
||||
},
|
||||
orderBy: {
|
||||
joinedAt: "asc",
|
||||
},
|
||||
});
|
||||
if (nextHost) {
|
||||
const currentParty = await dbClient.query.party.findFirst({
|
||||
where: {
|
||||
id: member.partyId,
|
||||
},
|
||||
});
|
||||
if (currentParty?.hostId === userId) {
|
||||
await dbClient
|
||||
.update(party)
|
||||
.set({
|
||||
hostId: nextHost.userId,
|
||||
lastUpdated: new Date(),
|
||||
})
|
||||
.where(eq(party.id, member.partyId));
|
||||
}
|
||||
}
|
||||
await cleanupPartyIfEmpty(dbClient, member.partyId);
|
||||
return member.partyId;
|
||||
const member = await getMemberRecord(dbClient, userId);
|
||||
if (!member) return null;
|
||||
await dbClient.delete(partyMember).where(eq(partyMember.id, member.id));
|
||||
const nextHost = await dbClient.query.partyMember.findFirst({
|
||||
where: {
|
||||
partyId: member.partyId,
|
||||
},
|
||||
orderBy: {
|
||||
joinedAt: "asc",
|
||||
},
|
||||
});
|
||||
let newHostId: string | null = null;
|
||||
if (nextHost) {
|
||||
const currentParty = await dbClient.query.party.findFirst({
|
||||
where: {
|
||||
id: member.partyId,
|
||||
},
|
||||
});
|
||||
if (currentParty?.hostId === userId) {
|
||||
await dbClient
|
||||
.update(party)
|
||||
.set({
|
||||
hostId: nextHost.userId,
|
||||
lastUpdated: new Date(),
|
||||
})
|
||||
.where(eq(party.id, member.partyId));
|
||||
newHostId = nextHost.userId;
|
||||
}
|
||||
}
|
||||
await cleanupPartyIfEmpty(dbClient, member.partyId);
|
||||
return {
|
||||
partyId: member.partyId,
|
||||
newHostId,
|
||||
};
|
||||
}
|
||||
|
||||
function isValidStatus(status: string): status is PartyStatus {
|
||||
return PARTY_STATUS.includes(status as PartyStatus);
|
||||
return PARTY_STATUS.includes(status as PartyStatus);
|
||||
}
|
||||
|
||||
export const partyApp = new Elysia()
|
||||
.use(betterAuthElysia)
|
||||
.group("/party", (app) =>
|
||||
app
|
||||
.get(
|
||||
"/status",
|
||||
async ({ user }) => {
|
||||
const currentParty = await getPartyForUser(user.id);
|
||||
if (!currentParty) return { party: null, members: [] };
|
||||
const status = await getPartyStatus(currentParty.id);
|
||||
return status ?? { party: null, members: [] };
|
||||
},
|
||||
{ auth: true },
|
||||
)
|
||||
.post(
|
||||
"/join",
|
||||
async ({ user, body, set }) => {
|
||||
const targetUserId = body.targetUserId;
|
||||
const targetUser = await db.query.user.findFirst({
|
||||
where: {
|
||||
id: targetUserId,
|
||||
},
|
||||
});
|
||||
if (!targetUser) {
|
||||
set.status = 404;
|
||||
return { error: "Target user not found." };
|
||||
}
|
||||
.use(betterAuthElysia)
|
||||
.group("/party", (app) =>
|
||||
app
|
||||
.ws("/ws", {
|
||||
beforeHandle: async ({ request, set }) => {
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers,
|
||||
});
|
||||
if (!session) {
|
||||
set.status = 401;
|
||||
return;
|
||||
}
|
||||
return {
|
||||
user: session.user,
|
||||
session: session.session,
|
||||
};
|
||||
},
|
||||
open: async (ws) => {
|
||||
const data = ws.data as unknown as PartyWsData;
|
||||
const user = data.user;
|
||||
if (!user) return;
|
||||
const membership = await getMemberRecord(db, user.id);
|
||||
if (!membership) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "You are not in a party.",
|
||||
}),
|
||||
);
|
||||
ws.close?.(1008, "Not in a party");
|
||||
return;
|
||||
}
|
||||
|
||||
let partyId: string | null = null;
|
||||
await db.transaction(async (tx) => {
|
||||
await leaveParty(tx, user.id);
|
||||
const snapshot = await getPartyStatus(membership.partyId);
|
||||
data.partyId = membership.partyId;
|
||||
registerPartySocket(membership.partyId, user.id, ws);
|
||||
if (snapshot) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "snapshot",
|
||||
party: snapshot.party,
|
||||
members: snapshot.members,
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
message: async (ws, message: PartySocketMessage) => {
|
||||
const data = ws.data as unknown as PartyWsData;
|
||||
const user = data.user;
|
||||
if (!user) return;
|
||||
if (message.type === "ping") {
|
||||
ws.send(JSON.stringify({ type: "pong" }));
|
||||
return;
|
||||
}
|
||||
|
||||
const targetMembership = await getMemberRecord(tx, targetUserId);
|
||||
if (targetMembership) {
|
||||
partyId = targetMembership.partyId;
|
||||
await tx
|
||||
.update(party)
|
||||
.set({
|
||||
hostId: targetUserId,
|
||||
lastUpdated: new Date(),
|
||||
})
|
||||
.where(eq(party.id, partyId));
|
||||
} else {
|
||||
const created = await tx
|
||||
.insert(party)
|
||||
.values({
|
||||
status: "created",
|
||||
hostId: targetUserId,
|
||||
})
|
||||
.returning({ id: party.id });
|
||||
partyId = created[0]!.id;
|
||||
await tx.insert(partyMember).values({
|
||||
partyId,
|
||||
userId: targetUserId,
|
||||
});
|
||||
}
|
||||
if (message.type !== "member_payload") return;
|
||||
const membership = await getMemberRecord(db, user.id);
|
||||
if (!membership) return;
|
||||
|
||||
await tx
|
||||
.insert(partyMember)
|
||||
.values({ partyId, userId: user.id })
|
||||
.onConflictDoNothing();
|
||||
});
|
||||
if (getPayloadSize(message.payload) > MAX_MEMBER_PAYLOAD_SIZE) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Payload too large.",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!partyId) return { party: null, members: [] };
|
||||
const status = await getPartyStatus(partyId);
|
||||
return status ?? { party: null, members: [] };
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
body: t.Object({
|
||||
targetUserId: t.String(),
|
||||
}),
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/leave",
|
||||
async ({ user }) => {
|
||||
const partyId = await db.transaction(async (tx) => {
|
||||
return await leaveParty(tx, user.id);
|
||||
});
|
||||
if (!partyId) return { party: null, members: [] };
|
||||
const status = await getPartyStatus(partyId);
|
||||
return status ?? { party: null, members: [] };
|
||||
},
|
||||
{ auth: true },
|
||||
)
|
||||
.post(
|
||||
"/kick",
|
||||
async ({ user, body, set }) => {
|
||||
const currentMembership = await getMemberRecord(db, user.id);
|
||||
if (!currentMembership) {
|
||||
set.status = 400;
|
||||
return { error: "You are not in a party." };
|
||||
}
|
||||
const currentParty = await db.query.party.findFirst({
|
||||
where: { id: membership.partyId },
|
||||
});
|
||||
if (!currentParty) return;
|
||||
|
||||
const currentParty = await db.query.party.findFirst({
|
||||
where: {
|
||||
id: currentMembership.partyId,
|
||||
},
|
||||
});
|
||||
if (!currentParty || currentParty.hostId !== user.id) {
|
||||
set.status = 403;
|
||||
return { error: "Only the host can kick members." };
|
||||
}
|
||||
sendPartyEventToUser(membership.partyId, currentParty.hostId, {
|
||||
type: "member_payload",
|
||||
fromUserId: user.id,
|
||||
payload: message.payload,
|
||||
});
|
||||
},
|
||||
close: async (ws) => {
|
||||
const data = ws.data as unknown as PartyWsData;
|
||||
const user = data.user;
|
||||
const { partyId } = data;
|
||||
if (!user) return;
|
||||
if (!partyId) {
|
||||
const membership = await getMemberRecord(db, user.id);
|
||||
if (!membership) return;
|
||||
unregisterPartySocket(membership.partyId, user.id, ws);
|
||||
return;
|
||||
}
|
||||
unregisterPartySocket(partyId, user.id, ws);
|
||||
},
|
||||
body: t.Union([
|
||||
t.Object({ type: t.Literal("ping") }),
|
||||
t.Object({ type: t.Literal("member_payload"), payload: t.Any() }),
|
||||
]),
|
||||
})
|
||||
.get(
|
||||
"/status",
|
||||
async ({ user }) => {
|
||||
const currentParty = await getPartyForUser(user.id);
|
||||
if (!currentParty) return { party: null, members: [] };
|
||||
const status = await getPartyStatus(currentParty.id);
|
||||
return status ?? { party: null, members: [] };
|
||||
},
|
||||
{ auth: true },
|
||||
)
|
||||
.post(
|
||||
"/join",
|
||||
async ({ user, body, set }) => {
|
||||
const targetUserId = body.targetUserId;
|
||||
const targetUser = await db.query.user.findFirst({
|
||||
where: {
|
||||
id: targetUserId,
|
||||
},
|
||||
});
|
||||
if (!targetUser) {
|
||||
set.status = 404;
|
||||
return { error: "Target user not found." };
|
||||
}
|
||||
|
||||
if (body.memberUserId === user.id) {
|
||||
set.status = 400;
|
||||
return { error: "Host cannot kick themselves." };
|
||||
}
|
||||
const { partyId, hostChanged, leaveResult } = await db.transaction(
|
||||
async (tx) => {
|
||||
const leaveResult = await leaveParty(tx, user.id);
|
||||
let partyId: string | null = null;
|
||||
let hostChanged = false;
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.delete(partyMember)
|
||||
.where(
|
||||
and(
|
||||
eq(partyMember.partyId, currentMembership.partyId),
|
||||
eq(partyMember.userId, body.memberUserId),
|
||||
),
|
||||
);
|
||||
await cleanupPartyIfEmpty(tx, currentMembership.partyId);
|
||||
});
|
||||
const status = await getPartyStatus(currentMembership.partyId);
|
||||
return status ?? { party: null, members: [] };
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
body: t.Object({
|
||||
memberUserId: t.String(),
|
||||
}),
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/status",
|
||||
async ({ user, body, set }) => {
|
||||
const currentMembership = await getMemberRecord(db, user.id);
|
||||
if (!currentMembership) {
|
||||
set.status = 400;
|
||||
return { error: "You are not in a party." };
|
||||
}
|
||||
const targetMembership = await getMemberRecord(tx, targetUserId);
|
||||
if (targetMembership) {
|
||||
partyId = targetMembership.partyId;
|
||||
await tx
|
||||
.update(party)
|
||||
.set({
|
||||
hostId: targetUserId,
|
||||
lastUpdated: new Date(),
|
||||
})
|
||||
.where(eq(party.id, partyId));
|
||||
hostChanged = true;
|
||||
} else {
|
||||
const created = await tx
|
||||
.insert(party)
|
||||
.values({
|
||||
status: "created",
|
||||
hostId: targetUserId,
|
||||
})
|
||||
.returning({ id: party.id });
|
||||
const createdId = created[0]?.id ?? null;
|
||||
if (!createdId) {
|
||||
return {
|
||||
partyId: null,
|
||||
hostChanged,
|
||||
leaveResult,
|
||||
};
|
||||
}
|
||||
partyId = createdId;
|
||||
await tx.insert(partyMember).values({
|
||||
partyId,
|
||||
userId: targetUserId,
|
||||
});
|
||||
}
|
||||
|
||||
const currentParty = await db.query.party.findFirst({
|
||||
where: {
|
||||
id: currentMembership.partyId,
|
||||
},
|
||||
});
|
||||
if (!currentParty || currentParty.hostId !== user.id) {
|
||||
set.status = 403;
|
||||
return { error: "Only the host can update party status." };
|
||||
}
|
||||
if (!partyId) {
|
||||
return {
|
||||
partyId: null,
|
||||
hostChanged,
|
||||
leaveResult,
|
||||
};
|
||||
}
|
||||
|
||||
if (!isValidStatus(body.status)) {
|
||||
set.status = 400;
|
||||
return { error: "Invalid party status." };
|
||||
}
|
||||
await tx
|
||||
.insert(partyMember)
|
||||
.values({ partyId, userId: user.id })
|
||||
.onConflictDoNothing();
|
||||
|
||||
const currentData =
|
||||
currentParty?.data && typeof currentParty.data === "object"
|
||||
? currentParty.data
|
||||
: {};
|
||||
const nextData = body.data
|
||||
? { ...currentData, ...body.data }
|
||||
: currentData;
|
||||
return {
|
||||
partyId,
|
||||
hostChanged,
|
||||
leaveResult,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(party)
|
||||
.set({
|
||||
status: body.status,
|
||||
data: nextData,
|
||||
lastUpdated: new Date(),
|
||||
})
|
||||
.where(eq(party.id, currentMembership.partyId));
|
||||
});
|
||||
if (!partyId) return { party: null, members: [] };
|
||||
const status = await getPartyStatus(partyId);
|
||||
if (leaveResult?.newHostId) {
|
||||
broadcastPartyEvent(leaveResult.partyId, {
|
||||
type: "host_changed",
|
||||
hostId: leaveResult.newHostId,
|
||||
});
|
||||
}
|
||||
if (hostChanged) {
|
||||
broadcastPartyEvent(partyId, {
|
||||
type: "host_changed",
|
||||
hostId: targetUserId,
|
||||
});
|
||||
}
|
||||
broadcastPartyEvent(partyId, {
|
||||
type: "member_joined",
|
||||
userId: user.id,
|
||||
});
|
||||
broadcastSnapshot(partyId, status);
|
||||
return status ?? { party: null, members: [] };
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
body: t.Object({
|
||||
targetUserId: t.String(),
|
||||
}),
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/leave",
|
||||
async ({ user }) => {
|
||||
const result = await db.transaction(async (tx) => {
|
||||
return await leaveParty(tx, user.id);
|
||||
});
|
||||
if (!result) return { party: null, members: [] };
|
||||
const status = await getPartyStatus(result.partyId);
|
||||
broadcastPartyEvent(result.partyId, {
|
||||
type: "member_left",
|
||||
userId: user.id,
|
||||
});
|
||||
if (result.newHostId) {
|
||||
broadcastPartyEvent(result.partyId, {
|
||||
type: "host_changed",
|
||||
hostId: result.newHostId,
|
||||
});
|
||||
}
|
||||
broadcastSnapshot(result.partyId, status);
|
||||
return status ?? { party: null, members: [] };
|
||||
},
|
||||
{ auth: true },
|
||||
)
|
||||
.post(
|
||||
"/kick",
|
||||
async ({ user, body, set }) => {
|
||||
const currentMembership = await getMemberRecord(db, user.id);
|
||||
if (!currentMembership) {
|
||||
set.status = 400;
|
||||
return { error: "You are not in a party." };
|
||||
}
|
||||
|
||||
const status = await getPartyStatus(currentMembership.partyId);
|
||||
return status ?? { party: null, members: [] };
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
body: t.Object({
|
||||
status: t.Enum({ created: "created", started: "started" }),
|
||||
data: t.Optional(t.Any()),
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
const currentParty = await db.query.party.findFirst({
|
||||
where: {
|
||||
id: currentMembership.partyId,
|
||||
},
|
||||
});
|
||||
if (!currentParty || currentParty.hostId !== user.id) {
|
||||
set.status = 403;
|
||||
return { error: "Only the host can kick members." };
|
||||
}
|
||||
|
||||
if (body.memberUserId === user.id) {
|
||||
set.status = 400;
|
||||
return { error: "Host cannot kick themselves." };
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.delete(partyMember)
|
||||
.where(
|
||||
and(
|
||||
eq(partyMember.partyId, currentMembership.partyId),
|
||||
eq(partyMember.userId, body.memberUserId),
|
||||
),
|
||||
);
|
||||
await cleanupPartyIfEmpty(tx, currentMembership.partyId);
|
||||
});
|
||||
const status = await getPartyStatus(currentMembership.partyId);
|
||||
broadcastPartyEvent(currentMembership.partyId, {
|
||||
type: "member_left",
|
||||
userId: body.memberUserId,
|
||||
kickedBy: user.id,
|
||||
});
|
||||
broadcastSnapshot(currentMembership.partyId, status);
|
||||
return status ?? { party: null, members: [] };
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
body: t.Object({
|
||||
memberUserId: t.String(),
|
||||
}),
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/status",
|
||||
async ({ user, body, set }) => {
|
||||
const currentMembership = await getMemberRecord(db, user.id);
|
||||
if (!currentMembership) {
|
||||
set.status = 400;
|
||||
return { error: "You are not in a party." };
|
||||
}
|
||||
|
||||
const currentParty = await db.query.party.findFirst({
|
||||
where: {
|
||||
id: currentMembership.partyId,
|
||||
},
|
||||
});
|
||||
if (!currentParty || currentParty.hostId !== user.id) {
|
||||
set.status = 403;
|
||||
return { error: "Only the host can update party status." };
|
||||
}
|
||||
|
||||
if (!isValidStatus(body.status)) {
|
||||
set.status = 400;
|
||||
return { error: "Invalid party status." };
|
||||
}
|
||||
|
||||
const currentData =
|
||||
currentParty?.data && typeof currentParty.data === "object"
|
||||
? currentParty.data
|
||||
: {};
|
||||
const nextData = body.data
|
||||
? { ...currentData, ...body.data }
|
||||
: currentData;
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(party)
|
||||
.set({
|
||||
status: body.status,
|
||||
data: nextData,
|
||||
lastUpdated: new Date(),
|
||||
})
|
||||
.where(eq(party.id, currentMembership.partyId));
|
||||
});
|
||||
|
||||
const status = await getPartyStatus(currentMembership.partyId);
|
||||
broadcastSnapshot(currentMembership.partyId, status);
|
||||
return status ?? { party: null, members: [] };
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
body: t.Object({
|
||||
status: t.Enum({ created: "created", started: "started" }),
|
||||
data: t.Optional(t.Any()),
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,79 +1,79 @@
|
|||
import Elysia from "elysia";
|
||||
import { sql } from "drizzle-orm";
|
||||
import Elysia from "elysia";
|
||||
import { betterAuthElysia } from "../auth";
|
||||
import { db } from "../db";
|
||||
import {
|
||||
artistGenre,
|
||||
genre,
|
||||
savedTrack,
|
||||
topTrack,
|
||||
trackArtist,
|
||||
artistGenre,
|
||||
genre,
|
||||
savedTrack,
|
||||
topTrack,
|
||||
trackArtist,
|
||||
} from "../db/schema";
|
||||
|
||||
export const statsApp = new Elysia().use(betterAuthElysia).get(
|
||||
"/stats",
|
||||
async ({ user }) => {
|
||||
const topArtists = await db.query.topArtist.findMany({
|
||||
limit: 10,
|
||||
with: {
|
||||
artist: {
|
||||
with: {
|
||||
genres: true,
|
||||
images: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
const topTracks = await db.query.topTrack.findMany({
|
||||
limit: 10,
|
||||
with: {
|
||||
track: {
|
||||
with: {
|
||||
album: {
|
||||
with: {
|
||||
images: true,
|
||||
},
|
||||
},
|
||||
artists: {
|
||||
with: {
|
||||
genres: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
userId: user.id,
|
||||
timeline: "medium_term",
|
||||
},
|
||||
orderBy: {
|
||||
position: "desc",
|
||||
},
|
||||
});
|
||||
const recentTracks = await db.query.playbackHistory.findMany({
|
||||
limit: 10,
|
||||
with: {
|
||||
track: {
|
||||
with: {
|
||||
album: {
|
||||
with: {
|
||||
images: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
const topGenresResult = await db.execute<{
|
||||
name: string;
|
||||
count: number;
|
||||
}>(sql`
|
||||
"/stats",
|
||||
async ({ user }) => {
|
||||
const topArtists = await db.query.topArtist.findMany({
|
||||
limit: 10,
|
||||
with: {
|
||||
artist: {
|
||||
with: {
|
||||
genres: true,
|
||||
images: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
const topTracks = await db.query.topTrack.findMany({
|
||||
limit: 10,
|
||||
with: {
|
||||
track: {
|
||||
with: {
|
||||
album: {
|
||||
with: {
|
||||
images: true,
|
||||
},
|
||||
},
|
||||
artists: {
|
||||
with: {
|
||||
genres: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
userId: user.id,
|
||||
timeline: "medium_term",
|
||||
},
|
||||
orderBy: {
|
||||
position: "desc",
|
||||
},
|
||||
});
|
||||
const recentTracks = await db.query.playbackHistory.findMany({
|
||||
limit: 10,
|
||||
with: {
|
||||
track: {
|
||||
with: {
|
||||
album: {
|
||||
with: {
|
||||
images: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
const topGenresResult = await db.execute<{
|
||||
name: string;
|
||||
count: number;
|
||||
}>(sql`
|
||||
select ${genre.name} as name, count(*)::int as count
|
||||
from (
|
||||
select distinct ${trackArtist.trackId} as track_id, ${artistGenre.genreId} as genre_id
|
||||
|
|
@ -95,10 +95,10 @@ export const statsApp = new Elysia().use(betterAuthElysia).get(
|
|||
order by count desc
|
||||
limit 10
|
||||
`);
|
||||
const topGenres = topGenresResult.rows;
|
||||
return { topArtists, topTracks, recentTracks, topGenres };
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
},
|
||||
const topGenres = topGenresResult.rows;
|
||||
return { topArtists, topTracks, recentTracks, topGenres };
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,11 +3,11 @@ import { betterAuthElysia } from "../auth";
|
|||
import { spotifySyncWorkflow } from "../workflows/sync";
|
||||
|
||||
export const syncApp = new Elysia().use(betterAuthElysia).post(
|
||||
"/sync",
|
||||
async ({ user }) => {
|
||||
return await spotifySyncWorkflow.syncUser(user.id);
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
},
|
||||
"/sync",
|
||||
async ({ user }) => {
|
||||
return await spotifySyncWorkflow.syncUser(user.id);
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,246 +1,247 @@
|
|||
import { DBOS, ConfiguredInstance } from "@dbos-inc/dbos-sdk";
|
||||
import { SpotifyApi } from "@spotify/web-api-ts-sdk";
|
||||
import { ConfiguredInstance, DBOS } from "@dbos-inc/dbos-sdk";
|
||||
import type {
|
||||
Artist,
|
||||
PlayHistory,
|
||||
SavedAlbum,
|
||||
SavedTrack,
|
||||
Track,
|
||||
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,
|
||||
followedArtist,
|
||||
savedAlbum,
|
||||
savedTrack,
|
||||
topArtist,
|
||||
topTrack,
|
||||
} from "../db/schema";
|
||||
import {
|
||||
upsertFollowedArtists,
|
||||
upsertPlaybackHistory,
|
||||
upsertSavedAlbums,
|
||||
upsertSavedTracks,
|
||||
upsertTopArtists,
|
||||
upsertTopTracks,
|
||||
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[];
|
||||
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 };
|
||||
}
|
||||
|
||||
@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 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);
|
||||
}
|
||||
|
||||
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 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 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 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 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 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 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 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 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 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;
|
||||
after = artists.items[artists.items.length - 1]!.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 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 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;
|
||||
}
|
||||
|
||||
@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: "",
|
||||
});
|
||||
}
|
||||
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");
|
||||
|
|
|
|||
|
|
@ -1,30 +1,30 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
// Environment setup & latest features
|
||||
"lib": ["ESNext"],
|
||||
"target": "ESNext",
|
||||
"module": "Preserve",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
"experimentalDecorators": true,
|
||||
"compilerOptions": {
|
||||
// Environment setup & latest features
|
||||
"lib": ["ESNext"],
|
||||
"target": "ESNext",
|
||||
"module": "Preserve",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
"experimentalDecorators": true,
|
||||
|
||||
// Bundler mode
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
// Bundler mode
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
|
||||
// Best practices
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
// Best practices
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
|
||||
// Some stricter flags (disabled by default)
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false,
|
||||
},
|
||||
// Some stricter flags (disabled by default)
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue