Compare commits

..

No commits in common. "7738645a691821b3cbf0f821dcddc168c16e3983" and "d4f9a8c2ddff86e2bef09862b9eacbf02a4ea817" have entirely different histories.

11 changed files with 200 additions and 927 deletions

View file

@ -3,7 +3,6 @@ import {
boolean, boolean,
index, index,
integer, integer,
json,
pgEnum, pgEnum,
pgTable, pgTable,
primaryKey, primaryKey,
@ -11,49 +10,11 @@ import {
timestamp, timestamp,
uniqueIndex, uniqueIndex,
uuid, uuid,
varchar,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
import { user } from "./auth-schema"; import { user } from "./auth-schema";
export * from "./auth-schema"; export * from "./auth-schema";
export const partyStatus = pgEnum("party_status", [
"created",
"started",
"ended",
]);
export const party = pgTable("party", {
id: uuid().defaultRandom().primaryKey().notNull(),
hostId: text()
.references(() => user.id)
.notNull(),
data: json(),
analysisData: json(),
createdAt: timestamp().defaultNow().notNull(),
lastUpdated: timestamp().defaultNow().notNull(),
status: partyStatus().notNull(),
});
export const memberStatus = pgEnum("member_status", [
"connected",
"disconnected",
]);
export const partyMember = pgTable(
"party_member",
{
id: uuid().defaultRandom().primaryKey().notNull(),
partyId: uuid()
.references(() => party.id)
.notNull(),
userId: uuid()
.references(() => user.id)
.notNull(),
joinedAt: timestamp().defaultNow().notNull(),
lastSeen: timestamp().defaultNow().notNull(),
},
(partyMember) => [uniqueIndex().on(partyMember.partyId, partyMember.userId)],
);
export const platform = pgEnum("enum_platform", ["spotify", "apple"]); export const platform = pgEnum("enum_platform", ["spotify", "apple"]);
export const artist = pgTable("artist", { export const artist = pgTable("artist", {
@ -347,8 +308,6 @@ export const relations = defineRelations(
artistImage, artistImage,
followedArtist, followedArtist,
genre, genre,
party,
partyMember,
platformImage, platformImage,
playbackHistory, playbackHistory,
savedAlbum, savedAlbum,
@ -418,23 +377,6 @@ export const relations = defineRelations(
to: r.album.id.through(r.albumImage.albumId), to: r.album.id.through(r.albumImage.albumId),
}), }),
}, },
party: {
members: r.many.partyMember(),
host: r.one.user({
from: r.party.hostId,
to: r.user.id,
}),
},
partyMember: {
party: r.one.party({
from: r.partyMember.partyId,
to: r.party.id,
}),
user: r.one.user({
from: r.partyMember.userId,
to: r.user.id,
}),
},
artistImage: { artistImage: {
artist: r.one.artist({ artist: r.one.artist({
from: r.artistImage.artistId, from: r.artistImage.artistId,
@ -578,12 +520,5 @@ export const relations = defineRelations(
to: r.user.id, to: r.user.id,
}), }),
}, },
user: {
partyMembers: r.many.partyMember(),
hostedParties: r.many.party({
from: r.user.id,
to: r.party.hostId,
}),
},
}), }),
); );

View file

@ -29,7 +29,7 @@ import {
track, track,
trackArtist, trackArtist,
} from "./schema"; } from "./schema";
import { and, eq, inArray, sql } from "drizzle-orm"; import { and, eq, inArray } from "drizzle-orm";
import { defaultSdk } from "../auth"; import { defaultSdk } from "../auth";
export const PLATFORM_SPOTIFY = "spotify" as const; export const PLATFORM_SPOTIFY = "spotify" as const;
@ -43,38 +43,30 @@ type DbTransaction = Parameters<typeof db.transaction>[0] extends (
type DbLike = DbClient | DbTransaction; type DbLike = DbClient | DbTransaction;
export async function upsertImages(images: Image[], dbClient: DbLike = db) { export async function upsertImages(images: Image[], dbClient: DbLike = db) {
await dbClient await dbClient.insert(platformImage).values(
.insert(platformImage) images.map(({ url, height, width }) => ({
.values( platform: PLATFORM_SPOTIFY,
images.map(({ url, height, width }) => ({ url,
platform: PLATFORM_SPOTIFY, height,
url, width,
height, })),
width, );
})),
)
.onConflictDoNothing();
} }
export async function upsertGenres(genres: string[], dbClient: DbLike = db) { export async function upsertGenres(genres: string[], dbClient: DbLike = db) {
const values = genres.filter(Boolean).map((name) => ({ name })); await dbClient.insert(genre).values(genres.map((name) => ({ name })));
if (values.length === 0) return;
await dbClient.insert(genre).values(values).onConflictDoNothing();
} }
export async function upsertArtists(artists: Artist[], dbClient: DbLike = db) { export async function upsertArtists(artists: Artist[], dbClient: DbLike = db) {
await dbClient await dbClient.insert(artist).values(
.insert(artist) artists.map(({ id, name, images, genres, popularity, type }) => ({
.values( platform: PLATFORM_SPOTIFY,
artists.map(({ id, name, images, genres, popularity, type }) => ({ platform_id: id,
platform: PLATFORM_SPOTIFY, name,
platform_id: id, popularity,
name, type,
popularity, })),
type, );
})),
)
.onConflictDoNothing();
await upsertImages( await upsertImages(
artists.flatMap((a) => a.images), artists.flatMap((a) => a.images),
dbClient, dbClient,
@ -84,40 +76,34 @@ export async function upsertArtists(artists: Artist[], dbClient: DbLike = db) {
dbClient, dbClient,
); );
for (const spotifyArtist of artists) { for (const spotifyArtist of artists) {
await dbClient await dbClient.insert(artistImage).select(
.insert(artistImage) dbClient
.select( .select({
dbClient artistId: artist.id,
.select({ imageId: platformImage.id,
artistId: artist.id, })
imageId: platformImage.id, .from(platformImage)
}) .where(
.from(platformImage) and(
.where( eq(platformImage.platform, PLATFORM_SPOTIFY),
and( inArray(
eq(platformImage.platform, PLATFORM_SPOTIFY), platformImage.url,
inArray( spotifyArtist.images.map((t) => t.url),
platformImage.url,
spotifyArtist.images.map((t) => t.url),
),
), ),
) ),
.innerJoin(artist, eq(artist.platform_id, spotifyArtist.id)), )
) .innerJoin(artist, eq(artist.platform_id, spotifyArtist.id)),
.onConflictDoNothing(); );
await dbClient await dbClient.insert(artistGenre).select(
.insert(artistGenre) dbClient
.select( .select({
dbClient artistId: artist.id,
.select({ genreId: genre.id,
artistId: artist.id, })
genreId: genre.id, .from(genre)
}) .where(inArray(genre.name, spotifyArtist.genres))
.from(genre) .innerJoin(artist, eq(artist.platform_id, spotifyArtist.id)),
.where(inArray(genre.name, spotifyArtist.genres)) );
.innerJoin(artist, eq(artist.platform_id, spotifyArtist.id)),
)
.onConflictDoNothing();
} }
} }
@ -135,14 +121,7 @@ async function lookupMissingArtists(
) { ) {
const missingArtistIds = await getMissingArtists(artistIds, dbClient); const missingArtistIds = await getMissingArtists(artistIds, dbClient);
if (missingArtistIds.length === 0) return []; if (missingArtistIds.length === 0) return [];
let missingArtists: Artist[] = []; const missingArtists = await defaultSdk.artists.get(missingArtistIds);
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); await upsertArtists(missingArtists, dbClient);
return missingArtists; return missingArtists;
} }
@ -212,20 +191,17 @@ export async function upsertAlbums(
albums.flatMap((a) => a.artists), albums.flatMap((a) => a.artists),
dbClient, dbClient,
); );
await dbClient await dbClient.insert(album).values(
.insert(album) albums.map(({ id, name, type, popularity, release_date, label }) => ({
.values( platform: PLATFORM_SPOTIFY,
albums.map(({ id, name, type, popularity, release_date, label }) => ({ platform_id: id,
platform: PLATFORM_SPOTIFY, name,
platform_id: id, type,
name, popularity,
type, release_date: new Date(release_date),
popularity, label,
release_date: new Date(release_date), })),
label, );
})),
)
.onConflictDoNothing();
await upsertImages( await upsertImages(
albums.flatMap((a) => a.images), albums.flatMap((a) => a.images),
dbClient, dbClient,
@ -235,59 +211,49 @@ export async function upsertAlbums(
dbClient, dbClient,
); );
for (const spotifyAlbum of albums) { for (const spotifyAlbum of albums) {
await dbClient await dbClient.insert(albumImage).select(
.insert(albumImage) dbClient
.select( .select({
dbClient albumId: album.id,
.select({ imageId: platformImage.id,
albumId: album.id, })
imageId: platformImage.id, .from(platformImage)
}) .where(
.from(platformImage) and(
.where( eq(platformImage.platform, PLATFORM_SPOTIFY),
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( inArray(
artist.platform_id, platformImage.url,
spotifyAlbum.artists.map((t) => t.id), spotifyAlbum.images.map((t) => t.url),
), ),
) ),
.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(); .innerJoin(album, eq(album.platform_id, spotifyAlbum.id)),
);
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)),
);
await dbClient.insert(albumGenre).select(
dbClient
.select({
albumId: album.id,
genreId: genre.id,
})
.from(genre)
.where(inArray(genre.name, spotifyAlbum.genres))
.innerJoin(album, eq(album.platform_id, spotifyAlbum.id)),
);
} }
} }
@ -305,41 +271,35 @@ export async function upsertTracks(tracks: Track[], dbClient: DbLike = db) {
tracks.map((t) => t.album.id), tracks.map((t) => t.album.id),
dbClient, dbClient,
); );
await dbClient await dbClient.insert(track).values(
.insert(track) tracks.map((spotifyTrack) => ({
.values( albumId: albumIdMap.get(spotifyTrack.album.id)!,
tracks.map((spotifyTrack) => ({ name: spotifyTrack.name,
albumId: albumIdMap.get(spotifyTrack.album.id)!, platform: PLATFORM_SPOTIFY,
name: spotifyTrack.name, platform_id: spotifyTrack.id,
platform: PLATFORM_SPOTIFY, popularity: spotifyTrack.popularity,
platform_id: spotifyTrack.id, duration: spotifyTrack.duration_ms,
popularity: spotifyTrack.popularity, explicit: spotifyTrack.explicit,
duration: spotifyTrack.duration_ms, disc_number: spotifyTrack.disc_number,
explicit: spotifyTrack.explicit, track_number: spotifyTrack.track_number,
disc_number: spotifyTrack.disc_number, })),
track_number: spotifyTrack.track_number, );
})),
)
.onConflictDoNothing();
for (const spotifyTrack of tracks) { for (const spotifyTrack of tracks) {
await dbClient await dbClient.insert(trackArtist).select(
.insert(trackArtist) dbClient
.select( .select({
dbClient trackId: track.id,
.select({ artistId: artist.id,
trackId: track.id, })
artistId: artist.id, .from(artist)
}) .where(
.from(artist) inArray(
.where( artist.platform_id,
inArray( spotifyTrack.artists.map((t) => t.id),
artist.platform_id, ),
spotifyTrack.artists.map((t) => t.id), )
), .innerJoin(track, eq(track.platform_id, spotifyTrack.id)),
) );
.innerJoin(track, eq(track.platform_id, spotifyTrack.id)),
)
.onConflictDoNothing();
} }
} }
@ -355,17 +315,14 @@ export async function upsertTopArtists(
artists.map((t) => t.id), artists.map((t) => t.id),
dbClient, dbClient,
); );
await dbClient await dbClient.insert(topArtist).values(
.insert(topArtist) artists.map((spotifyArtist, index) => ({
.values( artistId: artistIdMap.get(spotifyArtist.id)!,
artists.map((spotifyArtist, index) => ({ position: index + 1,
artistId: artistIdMap.get(spotifyArtist.id)!, userId,
position: index + 1, timeline,
userId, })),
timeline, );
})),
)
.onConflictDoNothing();
} }
export async function upsertTopTracks( export async function upsertTopTracks(
@ -380,17 +337,14 @@ export async function upsertTopTracks(
tracks.map((t) => t.id), tracks.map((t) => t.id),
dbClient, dbClient,
); );
await dbClient await dbClient.insert(topTrack).values(
.insert(topTrack) tracks.map((spotifyTrack, index) => ({
.values( trackId: trackIdMap.get(spotifyTrack.id)!,
tracks.map((spotifyTrack, index) => ({ position: index + 1,
trackId: trackIdMap.get(spotifyTrack.id)!, userId,
position: index + 1, timeline,
userId, })),
timeline, );
})),
)
.onConflictDoNothing();
} }
export async function upsertSavedAlbums( export async function upsertSavedAlbums(
@ -405,16 +359,13 @@ export async function upsertSavedAlbums(
albums.map((t) => t.id), albums.map((t) => t.id),
dbClient, dbClient,
); );
await dbClient await dbClient.insert(savedAlbum).values(
.insert(savedAlbum) saved.map((item) => ({
.values( albumId: albumIdMap.get(item.album.id)!,
saved.map((item) => ({ userId,
albumId: albumIdMap.get(item.album.id)!, saved_at: new Date(item.added_at),
userId, })),
saved_at: new Date(item.added_at), );
})),
)
.onConflictDoNothing();
} }
export async function upsertSavedTracks( export async function upsertSavedTracks(
@ -429,16 +380,13 @@ export async function upsertSavedTracks(
tracks.map((t) => t.id), tracks.map((t) => t.id),
dbClient, dbClient,
); );
await dbClient await dbClient.insert(savedTrack).values(
.insert(savedTrack) saved.map((item) => ({
.values( trackId: trackIdMap.get(item.track.id)!,
saved.map((item) => ({ userId,
trackId: trackIdMap.get(item.track.id)!, saved_at: new Date(item.added_at),
userId, })),
saved_at: new Date(item.added_at), );
})),
)
.onConflictDoNothing();
} }
export async function upsertFollowedArtists( export async function upsertFollowedArtists(
@ -452,15 +400,12 @@ export async function upsertFollowedArtists(
artists.map((t) => t.id), artists.map((t) => t.id),
dbClient, dbClient,
); );
await dbClient await dbClient.insert(followedArtist).values(
.insert(followedArtist) artists.map((spotifyArtist) => ({
.values( artistId: artistIdMap.get(spotifyArtist.id)!,
artists.map((spotifyArtist) => ({ userId,
artistId: artistIdMap.get(spotifyArtist.id)!, })),
userId, );
})),
)
.onConflictDoNothing();
} }
export async function upsertPlaybackHistory( export async function upsertPlaybackHistory(
@ -475,14 +420,11 @@ export async function upsertPlaybackHistory(
tracks.map((t) => t.id), tracks.map((t) => t.id),
dbClient, dbClient,
); );
await dbClient await dbClient.insert(playbackHistory).values(
.insert(playbackHistory) items.map((item) => ({
.values( trackId: trackIdMap.get(item.track.id)!,
items.map((item) => ({ userId,
trackId: trackIdMap.get(item.track.id)!, played_at: new Date(item.played_at),
userId, })),
played_at: new Date(item.played_at), );
})),
)
.onConflictDoNothing();
} }

View file

@ -4,12 +4,10 @@ import { syncApp } from "./routes/sync";
import { DBOS } from "@dbos-inc/dbos-sdk"; import { DBOS } from "@dbos-inc/dbos-sdk";
import "./workflows/sync"; import "./workflows/sync";
import "./dbos.ts"; import "./dbos.ts";
import { statsApp } from "./routes/stats.ts";
import { partyApp } from "./routes/party";
const app = new Elysia() const app = new Elysia()
.use(betterAuthElysia) .use(betterAuthElysia)
.group("/api", (app) => app.use(syncApp).use(statsApp).use(partyApp)) .group("/api", (app) => app.use(syncApp))
.listen(4000); .listen(4000);
export type App = typeof app; export type App = typeof app;

View file

@ -1,298 +0,0 @@
import Elysia, { t } from "elysia";
import { and, eq } from "drizzle-orm";
import { betterAuthElysia } from "../auth";
import { db } from "../db";
import { party, partyMember } from "../db/schema";
const PARTY_STATUS = ["created", "started", "ended"] as const;
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;
type DbLike = DbClient | DbTransaction;
async function getPartyForUser(userId: string) {
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
);
}
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,
};
}
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));
}
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;
}
function isValidStatus(status: string): status is 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." };
}
let partyId: string | null = null;
await db.transaction(async (tx) => {
await leaveParty(tx, user.id);
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,
});
}
await tx
.insert(partyMember)
.values({ partyId, userId: user.id })
.onConflictDoNothing();
});
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: 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);
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);
return status ?? { party: null, members: [] };
},
{
auth: true,
body: t.Object({
status: t.Enum({ created: "created", started: "started" }),
data: t.Optional(t.Any()),
}),
},
),
);

View file

@ -1,104 +0,0 @@
import Elysia from "elysia";
import { sql } from "drizzle-orm";
import { betterAuthElysia } from "../auth";
import { db } from "../db";
import {
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`
select ${genre.name} as name, count(*)::int as count
from (
select distinct ${trackArtist.trackId} as track_id, ${artistGenre.genreId} as genre_id
from ${trackArtist}
inner join ${artistGenre} on ${artistGenre.artistId} = ${trackArtist.artistId}
inner join (
select ${topTrack.trackId} as track_id
from ${topTrack}
where ${topTrack.userId} = ${user.id}
and ${topTrack.timeline} = 'medium_term'
union
select ${savedTrack.trackId} as track_id
from ${savedTrack}
where ${savedTrack.userId} = ${user.id}
) as selected_tracks on selected_tracks.track_id = ${trackArtist.trackId}
) as genre_tracks
inner join ${genre} on ${genre.id} = genre_tracks.genre_id
group by ${genre.name}
order by count desc
limit 10
`);
const topGenres = topGenresResult.rows;
return { topArtists, topTracks, recentTracks, topGenres };
},
{
auth: true,
},
);

View file

@ -70,79 +70,34 @@ export class SpotifySyncWorkflow extends ConfiguredInstance {
}; };
} }
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() @DBOS.step()
private async persistTopArtists( private async persistSpotifyData(userId: string, data: SyncPayload) {
userId: string,
topArtistsByTimeline: Record<Timeline, Artist[]>,
) {
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
await tx.delete(topArtist).where(eq(topArtist.userId, userId)); 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) { for (const timeline of timelines) {
await upsertTopArtists( await upsertTopArtists(
userId, userId,
timeline, timeline,
topArtistsByTimeline[timeline], data.topArtistsByTimeline[timeline],
tx, 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) { for (const timeline of timelines) {
await upsertTopTracks( await upsertTopTracks(
userId, userId,
timeline, timeline,
topTracksByTimeline[timeline], data.topTracksByTimeline[timeline],
tx, tx,
); );
} }
}); await upsertFollowedArtists(userId, data.followedArtists, tx);
} await upsertSavedAlbums(userId, data.savedAlbums, tx);
await upsertSavedTracks(userId, data.savedTracks, tx);
@DBOS.step() await upsertPlaybackHistory(userId, data.recentlyPlayed, tx);
private async persistFollowedArtists(userId: string, artists: Artist[]) {
await db.transaction(async (tx) => {
await tx.delete(followedArtist).where(eq(followedArtist.userId, userId));
await upsertFollowedArtists(userId, artists, tx);
});
}
@DBOS.step()
private async persistSavedAlbums(userId: string, albums: SavedAlbum[]) {
await db.transaction(async (tx) => {
await tx.delete(savedAlbum).where(eq(savedAlbum.userId, userId));
await upsertSavedAlbums(userId, albums, tx);
});
}
@DBOS.step()
private async persistSavedTracks(userId: string, tracks: SavedTrack[]) {
await db.transaction(async (tx) => {
await tx.delete(savedTrack).where(eq(savedTrack.userId, userId));
await upsertSavedTracks(userId, tracks, tx);
});
}
@DBOS.step()
private async persistPlaybackHistory(userId: string, items: PlayHistory[]) {
await db.transaction(async (tx) => {
await upsertPlaybackHistory(userId, items, tx);
}); });
} }
@ -185,8 +140,8 @@ export class SpotifySyncWorkflow extends ConfiguredInstance {
const page = await sdk.currentUser.followedArtists(after, 50); const page = await sdk.currentUser.followedArtists(after, 50);
const artists = page.artists; const artists = page.artists;
followed.push(...artists.items); followed.push(...artists.items);
if (!artists.next || artists.items.length === 0) break; if (!artists.next) break;
after = artists.items[artists.items.length - 1]!.id; after = artists.next;
} }
return followed; return followed;
} }

View file

@ -1,107 +0,0 @@
import { client } from "#/lib/eden";
import { useQuery } from "@tanstack/react-query";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "#/components/ui/card";
import { Avatar, AvatarGroup, AvatarImage } from "#/components/ui/avatar";
import {
Item,
ItemContent,
ItemDescription,
ItemGroup,
ItemMedia,
ItemTitle,
} from "#/components/ui/item";
import { Badge } from "#/components/ui/badge";
import { Spinner } from "#/components/ui/spinner";
import { Section, SectionTitle } from "./ui/section";
const MAX_AVATARS = 6;
export function QuickStats() {
const { isLoading, data } = useQuery({
queryFn: () => client.api.stats.get(),
queryKey: ["stats"],
});
if (isLoading) {
return (
<Card size="sm">
<CardHeader>
<CardTitle>Quick stats</CardTitle>
<CardDescription>Loading your music highlights.</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2 text-muted-foreground">
<Spinner />
Fetching stats
</div>
</CardContent>
</Card>
);
}
const topArtists = data?.data?.topArtists ?? [];
const topTracks = data?.data?.topTracks ?? [];
const topGenres = data?.data?.topGenres ?? [];
return (
<Section>
<SectionTitle>Data overview</SectionTitle>
<ItemGroup>
<Item size="sm" variant="muted">
<ItemMedia>
<AvatarGroup>
{topArtists.slice(0, MAX_AVATARS).map((entry) => (
<Avatar key={entry.artistId} size="sm">
<AvatarImage
src={entry.artist?.images?.[0]?.url || undefined}
alt={entry.artist?.name || ""}
/>
</Avatar>
))}
</AvatarGroup>
</ItemMedia>
<ItemContent>
<ItemTitle>Artists</ItemTitle>
</ItemContent>
</Item>
<Item size="sm" variant="muted">
<ItemMedia>
<AvatarGroup>
{topTracks.slice(0, MAX_AVATARS).map((entry) => (
<Avatar key={entry.trackId} size="sm">
<AvatarImage
src={entry.track?.album?.images?.[0]?.url || undefined}
alt={entry.track?.name || ""}
/>
</Avatar>
))}
</AvatarGroup>
</ItemMedia>
<ItemContent>
<ItemTitle>Tracks</ItemTitle>
</ItemContent>
</Item>
<Item size="sm" variant="muted">
<ItemContent>
<ItemTitle>Genres</ItemTitle>
<ItemDescription className="flex flex-wrap gap-2">
{topGenres.length === 0
? "No genres yet."
: topGenres.slice(0, 8).map((genre) => (
<Badge key={genre.name} variant="outline">
{genre.name}
</Badge>
))}
</ItemDescription>
</ItemContent>
</Item>
</ItemGroup>
</Section>
);
}

View file

@ -1,33 +1,22 @@
import { client } from "#/lib/eden"; import { client } from "#/lib/eden";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { useQueryClient } from "@tanstack/react-query";
import { Item, ItemActions, ItemDescription, ItemTitle } from "./ui/item";
export function SyncButton() { export function SyncButton() {
const query = useQueryClient();
return ( return (
<Item className="px-0 justify-between"> <Button
<ItemDescription>Sync your data</ItemDescription> onClick={async () => {
<ItemActions> toast.promise(
<Button client.api.sync.post().then((data) => console.log(data)),
onClick={async () => { {
toast.promise( loading: "Syncing...",
(async () => { success: "Synced!",
await client.api.sync.post(); error: "Sync failed",
query.invalidateQueries(); },
})(), );
{ }}
loading: "Syncing...", >
success: "Synced!", Sync
error: "Sync failed", </Button>
},
);
}}
>
Sync
</Button>
</ItemActions>
</Item>
); );
} }

View file

@ -1,11 +0,0 @@
import { cn } from "#/lib/utils";
export function MainContent({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return <main className={cn("min-h-screen p-8", className)}>{children}</main>;
}

View file

@ -1,25 +0,0 @@
import { cn } from "#/lib/utils";
export function Section({
className,
children,
}: {
className?: string;
children: React.ReactNode;
}) {
return <section className={cn("", className)}>{children}</section>;
}
export function SectionTitle({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<h2 className={cn("font-heading text-base font-medium", className)}>
{children}
</h2>
);
}

View file

@ -1,5 +1,4 @@
import { SyncButton } from "#/components/sync-button"; import { SyncButton } from "#/components/sync-button";
import { MainContent } from "#/components/ui/main-content";
import { UserInfo } from "#/components/user-info"; import { UserInfo } from "#/components/user-info";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
@ -7,9 +6,9 @@ export const Route = createFileRoute("/")({ component: App });
function App() { function App() {
return ( return (
<MainContent> <main>
<UserInfo /> <UserInfo />
<SyncButton /> <SyncButton />
</MainContent> </main>
); );
} }