Compare commits
4 commits
d4f9a8c2dd
...
7738645a69
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7738645a69 | ||
|
|
dd51cd10f5 | ||
|
|
a386920a90 | ||
|
|
332ddd76cf |
11 changed files with 928 additions and 201 deletions
|
|
@ -3,6 +3,7 @@ import {
|
||||||
boolean,
|
boolean,
|
||||||
index,
|
index,
|
||||||
integer,
|
integer,
|
||||||
|
json,
|
||||||
pgEnum,
|
pgEnum,
|
||||||
pgTable,
|
pgTable,
|
||||||
primaryKey,
|
primaryKey,
|
||||||
|
|
@ -10,11 +11,49 @@ 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", {
|
||||||
|
|
@ -308,6 +347,8 @@ export const relations = defineRelations(
|
||||||
artistImage,
|
artistImage,
|
||||||
followedArtist,
|
followedArtist,
|
||||||
genre,
|
genre,
|
||||||
|
party,
|
||||||
|
partyMember,
|
||||||
platformImage,
|
platformImage,
|
||||||
playbackHistory,
|
playbackHistory,
|
||||||
savedAlbum,
|
savedAlbum,
|
||||||
|
|
@ -377,6 +418,23 @@ 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,
|
||||||
|
|
@ -520,5 +578,12 @@ 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,
|
||||||
|
}),
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ import {
|
||||||
track,
|
track,
|
||||||
trackArtist,
|
trackArtist,
|
||||||
} from "./schema";
|
} from "./schema";
|
||||||
import { and, eq, inArray } from "drizzle-orm";
|
import { and, eq, inArray, sql } 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,30 +43,38 @@ 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.insert(platformImage).values(
|
await dbClient
|
||||||
images.map(({ url, height, width }) => ({
|
.insert(platformImage)
|
||||||
platform: PLATFORM_SPOTIFY,
|
.values(
|
||||||
url,
|
images.map(({ url, height, width }) => ({
|
||||||
height,
|
platform: PLATFORM_SPOTIFY,
|
||||||
width,
|
url,
|
||||||
})),
|
height,
|
||||||
);
|
width,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.onConflictDoNothing();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function upsertGenres(genres: string[], dbClient: DbLike = db) {
|
export async function upsertGenres(genres: string[], dbClient: DbLike = db) {
|
||||||
await dbClient.insert(genre).values(genres.map((name) => ({ name })));
|
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) {
|
export async function upsertArtists(artists: Artist[], dbClient: DbLike = db) {
|
||||||
await dbClient.insert(artist).values(
|
await dbClient
|
||||||
artists.map(({ id, name, images, genres, popularity, type }) => ({
|
.insert(artist)
|
||||||
platform: PLATFORM_SPOTIFY,
|
.values(
|
||||||
platform_id: id,
|
artists.map(({ id, name, images, genres, popularity, type }) => ({
|
||||||
name,
|
platform: PLATFORM_SPOTIFY,
|
||||||
popularity,
|
platform_id: id,
|
||||||
type,
|
name,
|
||||||
})),
|
popularity,
|
||||||
);
|
type,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.onConflictDoNothing();
|
||||||
await upsertImages(
|
await upsertImages(
|
||||||
artists.flatMap((a) => a.images),
|
artists.flatMap((a) => a.images),
|
||||||
dbClient,
|
dbClient,
|
||||||
|
|
@ -76,34 +84,40 @@ export async function upsertArtists(artists: Artist[], dbClient: DbLike = db) {
|
||||||
dbClient,
|
dbClient,
|
||||||
);
|
);
|
||||||
for (const spotifyArtist of artists) {
|
for (const spotifyArtist of artists) {
|
||||||
await dbClient.insert(artistImage).select(
|
await dbClient
|
||||||
dbClient
|
.insert(artistImage)
|
||||||
.select({
|
.select(
|
||||||
artistId: artist.id,
|
dbClient
|
||||||
imageId: platformImage.id,
|
.select({
|
||||||
})
|
artistId: artist.id,
|
||||||
.from(platformImage)
|
imageId: platformImage.id,
|
||||||
.where(
|
})
|
||||||
and(
|
.from(platformImage)
|
||||||
eq(platformImage.platform, PLATFORM_SPOTIFY),
|
.where(
|
||||||
inArray(
|
and(
|
||||||
platformImage.url,
|
eq(platformImage.platform, PLATFORM_SPOTIFY),
|
||||||
spotifyArtist.images.map((t) => t.url),
|
inArray(
|
||||||
|
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.insert(artistGenre).select(
|
await dbClient
|
||||||
dbClient
|
.insert(artistGenre)
|
||||||
.select({
|
.select(
|
||||||
artistId: artist.id,
|
dbClient
|
||||||
genreId: genre.id,
|
.select({
|
||||||
})
|
artistId: artist.id,
|
||||||
.from(genre)
|
genreId: genre.id,
|
||||||
.where(inArray(genre.name, spotifyArtist.genres))
|
})
|
||||||
.innerJoin(artist, eq(artist.platform_id, spotifyArtist.id)),
|
.from(genre)
|
||||||
);
|
.where(inArray(genre.name, spotifyArtist.genres))
|
||||||
|
.innerJoin(artist, eq(artist.platform_id, spotifyArtist.id)),
|
||||||
|
)
|
||||||
|
.onConflictDoNothing();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -121,7 +135,14 @@ 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 [];
|
||||||
const missingArtists = await defaultSdk.artists.get(missingArtistIds);
|
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);
|
await upsertArtists(missingArtists, dbClient);
|
||||||
return missingArtists;
|
return missingArtists;
|
||||||
}
|
}
|
||||||
|
|
@ -191,17 +212,20 @@ export async function upsertAlbums(
|
||||||
albums.flatMap((a) => a.artists),
|
albums.flatMap((a) => a.artists),
|
||||||
dbClient,
|
dbClient,
|
||||||
);
|
);
|
||||||
await dbClient.insert(album).values(
|
await dbClient
|
||||||
albums.map(({ id, name, type, popularity, release_date, label }) => ({
|
.insert(album)
|
||||||
platform: PLATFORM_SPOTIFY,
|
.values(
|
||||||
platform_id: id,
|
albums.map(({ id, name, type, popularity, release_date, label }) => ({
|
||||||
name,
|
platform: PLATFORM_SPOTIFY,
|
||||||
type,
|
platform_id: id,
|
||||||
popularity,
|
name,
|
||||||
release_date: new Date(release_date),
|
type,
|
||||||
label,
|
popularity,
|
||||||
})),
|
release_date: new Date(release_date),
|
||||||
);
|
label,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.onConflictDoNothing();
|
||||||
await upsertImages(
|
await upsertImages(
|
||||||
albums.flatMap((a) => a.images),
|
albums.flatMap((a) => a.images),
|
||||||
dbClient,
|
dbClient,
|
||||||
|
|
@ -211,49 +235,59 @@ export async function upsertAlbums(
|
||||||
dbClient,
|
dbClient,
|
||||||
);
|
);
|
||||||
for (const spotifyAlbum of albums) {
|
for (const spotifyAlbum of albums) {
|
||||||
await dbClient.insert(albumImage).select(
|
await dbClient
|
||||||
dbClient
|
.insert(albumImage)
|
||||||
.select({
|
.select(
|
||||||
albumId: album.id,
|
dbClient
|
||||||
imageId: platformImage.id,
|
.select({
|
||||||
})
|
albumId: album.id,
|
||||||
.from(platformImage)
|
imageId: platformImage.id,
|
||||||
.where(
|
})
|
||||||
and(
|
.from(platformImage)
|
||||||
eq(platformImage.platform, PLATFORM_SPOTIFY),
|
.where(
|
||||||
inArray(
|
and(
|
||||||
platformImage.url,
|
eq(platformImage.platform, PLATFORM_SPOTIFY),
|
||||||
spotifyAlbum.images.map((t) => t.url),
|
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)),
|
||||||
)
|
)
|
||||||
.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)),
|
|
||||||
);
|
|
||||||
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)),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -271,35 +305,41 @@ 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.insert(track).values(
|
await dbClient
|
||||||
tracks.map((spotifyTrack) => ({
|
.insert(track)
|
||||||
albumId: albumIdMap.get(spotifyTrack.album.id)!,
|
.values(
|
||||||
name: spotifyTrack.name,
|
tracks.map((spotifyTrack) => ({
|
||||||
platform: PLATFORM_SPOTIFY,
|
albumId: albumIdMap.get(spotifyTrack.album.id)!,
|
||||||
platform_id: spotifyTrack.id,
|
name: spotifyTrack.name,
|
||||||
popularity: spotifyTrack.popularity,
|
platform: PLATFORM_SPOTIFY,
|
||||||
duration: spotifyTrack.duration_ms,
|
platform_id: spotifyTrack.id,
|
||||||
explicit: spotifyTrack.explicit,
|
popularity: spotifyTrack.popularity,
|
||||||
disc_number: spotifyTrack.disc_number,
|
duration: spotifyTrack.duration_ms,
|
||||||
track_number: spotifyTrack.track_number,
|
explicit: spotifyTrack.explicit,
|
||||||
})),
|
disc_number: spotifyTrack.disc_number,
|
||||||
);
|
track_number: spotifyTrack.track_number,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.onConflictDoNothing();
|
||||||
for (const spotifyTrack of tracks) {
|
for (const spotifyTrack of tracks) {
|
||||||
await dbClient.insert(trackArtist).select(
|
await dbClient
|
||||||
dbClient
|
.insert(trackArtist)
|
||||||
.select({
|
.select(
|
||||||
trackId: track.id,
|
dbClient
|
||||||
artistId: artist.id,
|
.select({
|
||||||
})
|
trackId: track.id,
|
||||||
.from(artist)
|
artistId: artist.id,
|
||||||
.where(
|
})
|
||||||
inArray(
|
.from(artist)
|
||||||
artist.platform_id,
|
.where(
|
||||||
spotifyTrack.artists.map((t) => t.id),
|
inArray(
|
||||||
),
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -315,14 +355,17 @@ export async function upsertTopArtists(
|
||||||
artists.map((t) => t.id),
|
artists.map((t) => t.id),
|
||||||
dbClient,
|
dbClient,
|
||||||
);
|
);
|
||||||
await dbClient.insert(topArtist).values(
|
await dbClient
|
||||||
artists.map((spotifyArtist, index) => ({
|
.insert(topArtist)
|
||||||
artistId: artistIdMap.get(spotifyArtist.id)!,
|
.values(
|
||||||
position: index + 1,
|
artists.map((spotifyArtist, index) => ({
|
||||||
userId,
|
artistId: artistIdMap.get(spotifyArtist.id)!,
|
||||||
timeline,
|
position: index + 1,
|
||||||
})),
|
userId,
|
||||||
);
|
timeline,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.onConflictDoNothing();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function upsertTopTracks(
|
export async function upsertTopTracks(
|
||||||
|
|
@ -337,14 +380,17 @@ export async function upsertTopTracks(
|
||||||
tracks.map((t) => t.id),
|
tracks.map((t) => t.id),
|
||||||
dbClient,
|
dbClient,
|
||||||
);
|
);
|
||||||
await dbClient.insert(topTrack).values(
|
await dbClient
|
||||||
tracks.map((spotifyTrack, index) => ({
|
.insert(topTrack)
|
||||||
trackId: trackIdMap.get(spotifyTrack.id)!,
|
.values(
|
||||||
position: index + 1,
|
tracks.map((spotifyTrack, index) => ({
|
||||||
userId,
|
trackId: trackIdMap.get(spotifyTrack.id)!,
|
||||||
timeline,
|
position: index + 1,
|
||||||
})),
|
userId,
|
||||||
);
|
timeline,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.onConflictDoNothing();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function upsertSavedAlbums(
|
export async function upsertSavedAlbums(
|
||||||
|
|
@ -359,13 +405,16 @@ export async function upsertSavedAlbums(
|
||||||
albums.map((t) => t.id),
|
albums.map((t) => t.id),
|
||||||
dbClient,
|
dbClient,
|
||||||
);
|
);
|
||||||
await dbClient.insert(savedAlbum).values(
|
await dbClient
|
||||||
saved.map((item) => ({
|
.insert(savedAlbum)
|
||||||
albumId: albumIdMap.get(item.album.id)!,
|
.values(
|
||||||
userId,
|
saved.map((item) => ({
|
||||||
saved_at: new Date(item.added_at),
|
albumId: albumIdMap.get(item.album.id)!,
|
||||||
})),
|
userId,
|
||||||
);
|
saved_at: new Date(item.added_at),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.onConflictDoNothing();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function upsertSavedTracks(
|
export async function upsertSavedTracks(
|
||||||
|
|
@ -380,13 +429,16 @@ export async function upsertSavedTracks(
|
||||||
tracks.map((t) => t.id),
|
tracks.map((t) => t.id),
|
||||||
dbClient,
|
dbClient,
|
||||||
);
|
);
|
||||||
await dbClient.insert(savedTrack).values(
|
await dbClient
|
||||||
saved.map((item) => ({
|
.insert(savedTrack)
|
||||||
trackId: trackIdMap.get(item.track.id)!,
|
.values(
|
||||||
userId,
|
saved.map((item) => ({
|
||||||
saved_at: new Date(item.added_at),
|
trackId: trackIdMap.get(item.track.id)!,
|
||||||
})),
|
userId,
|
||||||
);
|
saved_at: new Date(item.added_at),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.onConflictDoNothing();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function upsertFollowedArtists(
|
export async function upsertFollowedArtists(
|
||||||
|
|
@ -400,12 +452,15 @@ export async function upsertFollowedArtists(
|
||||||
artists.map((t) => t.id),
|
artists.map((t) => t.id),
|
||||||
dbClient,
|
dbClient,
|
||||||
);
|
);
|
||||||
await dbClient.insert(followedArtist).values(
|
await dbClient
|
||||||
artists.map((spotifyArtist) => ({
|
.insert(followedArtist)
|
||||||
artistId: artistIdMap.get(spotifyArtist.id)!,
|
.values(
|
||||||
userId,
|
artists.map((spotifyArtist) => ({
|
||||||
})),
|
artistId: artistIdMap.get(spotifyArtist.id)!,
|
||||||
);
|
userId,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.onConflictDoNothing();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function upsertPlaybackHistory(
|
export async function upsertPlaybackHistory(
|
||||||
|
|
@ -420,11 +475,14 @@ export async function upsertPlaybackHistory(
|
||||||
tracks.map((t) => t.id),
|
tracks.map((t) => t.id),
|
||||||
dbClient,
|
dbClient,
|
||||||
);
|
);
|
||||||
await dbClient.insert(playbackHistory).values(
|
await dbClient
|
||||||
items.map((item) => ({
|
.insert(playbackHistory)
|
||||||
trackId: trackIdMap.get(item.track.id)!,
|
.values(
|
||||||
userId,
|
items.map((item) => ({
|
||||||
played_at: new Date(item.played_at),
|
trackId: trackIdMap.get(item.track.id)!,
|
||||||
})),
|
userId,
|
||||||
);
|
played_at: new Date(item.played_at),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.onConflictDoNothing();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,12 @@ 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))
|
.group("/api", (app) => app.use(syncApp).use(statsApp).use(partyApp))
|
||||||
.listen(4000);
|
.listen(4000);
|
||||||
|
|
||||||
export type App = typeof app;
|
export type App = typeof app;
|
||||||
|
|
|
||||||
298
api/src/routes/party.ts
Normal file
298
api/src/routes/party.ts
Normal file
|
|
@ -0,0 +1,298 @@
|
||||||
|
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()),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
104
api/src/routes/stats.ts
Normal file
104
api/src/routes/stats.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
@ -70,34 +70,79 @@ export class SpotifySyncWorkflow extends ConfiguredInstance {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@DBOS.step()
|
|
||||||
private async persistSpotifyData(userId: string, data: SyncPayload) {
|
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 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,
|
||||||
data.topArtistsByTimeline[timeline],
|
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,
|
||||||
data.topTracksByTimeline[timeline],
|
topTracksByTimeline[timeline],
|
||||||
tx,
|
tx,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await upsertFollowedArtists(userId, data.followedArtists, tx);
|
});
|
||||||
await upsertSavedAlbums(userId, data.savedAlbums, tx);
|
}
|
||||||
await upsertSavedTracks(userId, data.savedTracks, tx);
|
|
||||||
await upsertPlaybackHistory(userId, data.recentlyPlayed, tx);
|
@DBOS.step()
|
||||||
|
private async 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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -140,8 +185,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) break;
|
if (!artists.next || artists.items.length === 0) break;
|
||||||
after = artists.next;
|
after = artists.items[artists.items.length - 1]!.id;
|
||||||
}
|
}
|
||||||
return followed;
|
return followed;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
107
web/src/components/quick-stats.tsx
Normal file
107
web/src/components/quick-stats.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,22 +1,33 @@
|
||||||
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 (
|
||||||
<Button
|
<Item className="px-0 justify-between">
|
||||||
onClick={async () => {
|
<ItemDescription>Sync your data</ItemDescription>
|
||||||
toast.promise(
|
<ItemActions>
|
||||||
client.api.sync.post().then((data) => console.log(data)),
|
<Button
|
||||||
{
|
onClick={async () => {
|
||||||
loading: "Syncing...",
|
toast.promise(
|
||||||
success: "Synced!",
|
(async () => {
|
||||||
error: "Sync failed",
|
await client.api.sync.post();
|
||||||
},
|
query.invalidateQueries();
|
||||||
);
|
})(),
|
||||||
}}
|
{
|
||||||
>
|
loading: "Syncing...",
|
||||||
Sync
|
success: "Synced!",
|
||||||
</Button>
|
error: "Sync failed",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Sync
|
||||||
|
</Button>
|
||||||
|
</ItemActions>
|
||||||
|
</Item>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
11
web/src/components/ui/main-content.tsx
Normal file
11
web/src/components/ui/main-content.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
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>;
|
||||||
|
}
|
||||||
25
web/src/components/ui/section.tsx
Normal file
25
web/src/components/ui/section.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
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";
|
||||||
|
|
||||||
|
|
@ -6,9 +7,9 @@ export const Route = createFileRoute("/")({ component: App });
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<main>
|
<MainContent>
|
||||||
<UserInfo />
|
<UserInfo />
|
||||||
<SyncButton />
|
<SyncButton />
|
||||||
</main>
|
</MainContent>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue