first sync version

This commit is contained in:
Daniel Bulant 2026-04-19 22:08:24 +02:00
parent 6ae23f7c07
commit ba27e07749
No known key found for this signature in database
5 changed files with 657 additions and 10 deletions

View file

@ -3,10 +3,16 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle";
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!;
export const defaultSdk = SpotifyApi.withClientCredentials(
SPOTIFY_CLIENT_ID,
SPOTIFY_CLIENT_SECRET,
);
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: "pg",

View file

@ -1,4 +1,4 @@
import { drizzle } from "drizzle-orm/node-postgres";
import "./schema";
import { relations } from "./schema";
export const db = drizzle(process.env.DATABASE_URL!);
export const db = drizzle(process.env.DATABASE_URL!, { relations });

View file

@ -1,3 +1,4 @@
import { defineRelations } from "drizzle-orm";
import {
boolean,
index,
@ -7,6 +8,7 @@ import {
primaryKey,
text,
timestamp,
uniqueIndex,
uuid,
varchar,
} from "drizzle-orm/pg-core";
@ -26,6 +28,7 @@ export const artist = pgTable("artist", {
export const genre = pgTable("genre", {
id: uuid().defaultRandom().primaryKey().notNull(),
name: text().notNull().unique(),
});
export const artistGenre = pgTable(
@ -45,13 +48,20 @@ export const artistGenre = pgTable(
],
);
export const platformImage = pgTable("platform_image", {
id: uuid().defaultRandom().primaryKey().notNull(),
platform: platform().notNull(),
url: text(),
height: integer(),
width: integer(),
});
export const platformImage = pgTable(
"platform_image",
{
id: uuid().defaultRandom().primaryKey().notNull(),
platform: platform().notNull(),
url: text(),
height: integer(),
width: integer(),
},
(platformImage) => [
uniqueIndex().on(platformImage.platform, platformImage.url),
index().on(platformImage.url),
],
);
export const artistImage = pgTable(
"artist_image",
@ -98,6 +108,40 @@ export const albumImage = pgTable(
],
);
export const albumArtist = pgTable(
"album_artist",
{
album: uuid()
.references(() => album.id)
.notNull(),
artist: uuid()
.references(() => artist.id)
.notNull(),
},
(albumArtist) => [
primaryKey({
columns: [albumArtist.album, albumArtist.artist],
}),
],
);
export const albumGenre = pgTable(
"album_genre",
{
album: uuid()
.references(() => album.id)
.notNull(),
genre: uuid()
.references(() => genre.id)
.notNull(),
},
(albumGenre) => [
primaryKey({
columns: [albumGenre.album, albumGenre.genre],
}),
],
);
export const track = pgTable(
"track",
{
@ -252,3 +296,230 @@ export const playbackHistory = pgTable(
},
(playbackHistory) => [index().on([playbackHistory.user])],
);
export const relations = defineRelations(
{
album,
albumImage,
albumArtist,
albumGenre,
artist,
artistGenre,
artistImage,
followedArtist,
genre,
platformImage,
playbackHistory,
savedAlbum,
savedTrack,
topArtist,
topTrack,
track,
trackArtist,
user,
},
(r) => ({
artist: {
artistGenres: r.many.artistGenre(),
artistImages: r.many.artistImage(),
trackArtists: r.many.trackArtist(),
albumArtists: r.many.albumArtist(),
topArtists: r.many.topArtist(),
followedArtists: r.many.followedArtist(),
albumGenres: r.many.albumGenre(),
genres: r.many.genre({
from: r.artist.id.through(r.artistGenre.artist),
to: r.genre.id.through(r.artistGenre.genre),
}),
images: r.many.platformImage({
from: r.artist.id.through(r.artistImage.artist),
to: r.platformImage.id.through(r.artistImage.image),
}),
albums: r.many.album({
from: r.artist.id.through(r.albumArtist.artist),
to: r.album.id.through(r.albumArtist.album),
}),
tracks: r.many.track({
from: r.artist.id.through(r.trackArtist.artist),
to: r.track.id.through(r.trackArtist.track),
}),
},
genre: {
artistGenres: r.many.artistGenre(),
albumGenres: r.many.albumGenre(),
artists: r.many.artist({
from: r.genre.id.through(r.artistGenre.genre),
to: r.artist.id.through(r.artistGenre.artist),
}),
albums: r.many.album({
from: r.genre.id.through(r.albumGenre.genre),
to: r.album.id.through(r.albumGenre.album),
}),
},
artistGenre: {
artist: r.one.artist({
from: r.artistGenre.artist,
to: r.artist.id,
}),
genre: r.one.genre({
from: r.artistGenre.genre,
to: r.genre.id,
}),
},
platformImage: {
artistImages: r.many.artistImage(),
albumImages: r.many.albumImage(),
artists: r.many.artist({
from: r.platformImage.id.through(r.artistImage.image),
to: r.artist.id.through(r.artistImage.artist),
}),
albums: r.many.album({
from: r.platformImage.id.through(r.albumImage.image),
to: r.album.id.through(r.albumImage.album),
}),
},
artistImage: {
artist: r.one.artist({
from: r.artistImage.artist,
to: r.artist.id,
}),
image: r.one.platformImage({
from: r.artistImage.image,
to: r.platformImage.id,
}),
},
album: {
tracks: r.many.track(),
albumImages: r.many.albumImage(),
albumArtists: r.many.albumArtist(),
albumGenres: r.many.albumGenre(),
savedAlbums: r.many.savedAlbum(),
images: r.many.platformImage({
from: r.album.id.through(r.albumImage.album),
to: r.platformImage.id.through(r.albumImage.image),
}),
artists: r.many.artist({
from: r.album.id.through(r.albumArtist.album),
to: r.artist.id.through(r.albumArtist.artist),
}),
genres: r.many.genre({
from: r.album.id.through(r.albumGenre.album),
to: r.genre.id.through(r.albumGenre.genre),
}),
},
albumImage: {
album: r.one.album({
from: r.albumImage.album,
to: r.album.id,
}),
image: r.one.platformImage({
from: r.albumImage.image,
to: r.platformImage.id,
}),
},
albumArtist: {
album: r.one.album({
from: r.albumArtist.album,
to: r.album.id,
}),
artist: r.one.artist({
from: r.albumArtist.artist,
to: r.artist.id,
}),
},
albumGenre: {
album: r.one.album({
from: r.albumGenre.album,
to: r.album.id,
}),
genre: r.one.genre({
from: r.albumGenre.genre,
to: r.genre.id,
}),
},
track: {
album: r.one.album({
from: r.track.album,
to: r.album.id,
}),
trackArtists: r.many.trackArtist(),
topTracks: r.many.topTrack(),
savedTracks: r.many.savedTrack(),
playbackHistory: r.many.playbackHistory(),
artists: r.many.artist({
from: r.track.id.through(r.trackArtist.track),
to: r.artist.id.through(r.trackArtist.artist),
}),
},
trackArtist: {
track: r.one.track({
from: r.trackArtist.track,
to: r.track.id,
}),
artist: r.one.artist({
from: r.trackArtist.artist,
to: r.artist.id,
}),
},
topTrack: {
track: r.one.track({
from: r.topTrack.track,
to: r.track.id,
}),
user: r.one.user({
from: r.topTrack.user,
to: r.user.id,
}),
},
topArtist: {
artist: r.one.artist({
from: r.topArtist.artist,
to: r.artist.id,
}),
user: r.one.user({
from: r.topArtist.user,
to: r.user.id,
}),
},
savedAlbum: {
album: r.one.album({
from: r.savedAlbum.album,
to: r.album.id,
}),
user: r.one.user({
from: r.savedAlbum.user,
to: r.user.id,
}),
},
savedTrack: {
track: r.one.track({
from: r.savedTrack.track,
to: r.track.id,
}),
user: r.one.user({
from: r.savedTrack.user,
to: r.user.id,
}),
},
followedArtist: {
artist: r.one.artist({
from: r.followedArtist.artist,
to: r.artist.id,
}),
user: r.one.user({
from: r.followedArtist.user,
to: r.user.id,
}),
},
playbackHistory: {
track: r.one.track({
from: r.playbackHistory.track,
to: r.track.id,
}),
user: r.one.user({
from: r.playbackHistory.user,
to: r.user.id,
}),
},
}),
);

345
api/src/db/spotify.ts Normal file
View file

@ -0,0 +1,345 @@
import type {
Album,
Artist,
FollowedArtists,
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 } from "drizzle-orm";
import { defaultSdk } from "../auth";
export const PLATFORM_SPOTIFY = "spotify" as const;
export async function upsertImages(images: Image[]) {
await db.insert(platformImage).values(
images.map(({ url, height, width }) => ({
platform: PLATFORM_SPOTIFY,
url,
height,
width,
})),
);
}
export async function upsertGenres(genres: string[]) {
await db.insert(genre).values(genres.map((name) => ({ name })));
}
export async function upsertArtists(artists: Artist[]) {
await db.insert(artist).values(
artists.map(({ id, name, images, genres, popularity, type }) => ({
platform: PLATFORM_SPOTIFY,
platform_id: id,
name,
popularity,
type,
})),
);
await upsertImages(artists.flatMap((a) => a.images));
await upsertGenres(artists.flatMap((a) => a.genres));
for (const spotifyArtist of artists) {
await db.insert(artistImage).select(
db
.select({
artist: artist.id,
image: 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)),
);
await db.insert(artistGenre).select(
db
.select({
artist: artist.id,
genre: genre.id,
})
.from(genre)
.where(inArray(genre.name, spotifyArtist.genres))
.innerJoin(artist, eq(artist.platform_id, spotifyArtist.id)),
);
}
}
async function getMissingArtists(artistIds: string[]) {
const existingArtists = await db
.select({ id: artist.id })
.from(artist)
.where(inArray(artist.platform_id, artistIds));
return artistIds.filter((id) => !existingArtists.some((a) => a.id === id));
}
async function lookupMissingArtists(artistIds: string[]) {
const missingArtistIds = await getMissingArtists(artistIds);
if (missingArtistIds.length === 0) return [];
const missingArtists = await defaultSdk.artists.get(missingArtistIds);
await upsertArtists(missingArtists);
return missingArtists;
}
function isFullArtistArray(
artists: Artist[] | SimplifiedArtist[],
): artists is Artist[] {
return "images" in artists[0]!;
}
async function upsertMissingArtists(artists: SimplifiedArtist[] | Artist[]) {
if (artists.length === 0) return;
let missingArtists: Artist[];
if (isFullArtistArray(artists)) {
const missingArtistIds = await getMissingArtists(artists.map((t) => t.id));
if (missingArtistIds.length === 0) return;
missingArtists = artists.filter((a) => missingArtistIds.includes(a.id));
} else {
missingArtists = await lookupMissingArtists(artists.map((t) => t.id));
}
if (missingArtists.length === 0) return;
await upsertArtists(missingArtists);
return missingArtists;
}
async function getArtistIdMap(artistIds: string[]) {
if (artistIds.length === 0) return new Map<string, string>();
const rows = await db
.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[]) {
if (albumIds.length === 0) return new Map<string, string>();
const rows = await db
.select({ id: album.id, platform_id: album.platform_id })
.from(album)
.where(inArray(album.platform_id, albumIds));
return new Map(rows.map((row) => [row.platform_id ?? "", row.id]));
}
async function getTrackIdMap(trackIds: string[]) {
if (trackIds.length === 0) return new Map<string, string>();
const rows = await db
.select({ id: track.id, platform_id: track.platform_id })
.from(track)
.where(inArray(track.platform_id, trackIds));
return new Map(rows.map((row) => [row.platform_id ?? "", row.id]));
}
export async function upsertAlbums(albums: Album[] | SimplifiedAlbum[]) {
await upsertMissingArtists(albums.flatMap((a) => a.artists));
await db.insert(album).values(
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,
})),
);
await upsertImages(albums.flatMap((a) => a.images));
await upsertGenres(albums.flatMap((a) => a.genres));
for (const spotifyAlbum of albums) {
await db.insert(albumImage).select(
db
.select({
album: album.id,
image: 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)),
);
await db.insert(albumArtist).select(
db
.select({
album: album.id,
artist: artist.id,
})
.from(artist)
.where(inArray(artist.platform_id, spotifyAlbum.artists.map((t) => t.id)))
.innerJoin(album, eq(album.platform_id, spotifyAlbum.id)),
);
await db.insert(albumGenre).select(
db
.select({
album: album.id,
genre: genre.id,
})
.from(genre)
.where(inArray(genre.name, spotifyAlbum.genres))
.innerJoin(album, eq(album.platform_id, spotifyAlbum.id)),
);
}
}
export async function upsertTracks(tracks: Track[]) {
if (tracks.length === 0) return;
await upsertAlbums(tracks.map((t) => t.album));
await upsertMissingArtists(tracks.flatMap((t) => t.artists));
const albumIdMap = await getAlbumIdMap(tracks.map((t) => t.album.id));
await db.insert(track).values(
tracks.map((spotifyTrack) => ({
album: 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,
})),
);
for (const spotifyTrack of tracks) {
await db.insert(trackArtist).select(
db
.select({
track: track.id,
artist: artist.id,
})
.from(artist)
.where(inArray(artist.platform_id, spotifyTrack.artists.map((t) => t.id)))
.innerJoin(track, eq(track.platform_id, spotifyTrack.id)),
);
}
}
export async function upsertTopArtists(
userId: string,
timeline: "short_term" | "medium_term" | "long_term",
artists: Artist[],
) {
if (artists.length === 0) return;
await upsertArtists(artists);
const artistIdMap = await getArtistIdMap(artists.map((t) => t.id));
await db.insert(topArtist).values(
artists.map((spotifyArtist, index) => ({
artist: artistIdMap.get(spotifyArtist.id)!,
position: index + 1,
user: userId,
timeline,
})),
);
}
export async function upsertTopTracks(
userId: string,
timeline: "short_term" | "medium_term" | "long_term",
tracks: Track[],
) {
if (tracks.length === 0) return;
await upsertTracks(tracks);
const trackIdMap = await getTrackIdMap(tracks.map((t) => t.id));
await db.insert(topTrack).values(
tracks.map((spotifyTrack, index) => ({
track: trackIdMap.get(spotifyTrack.id)!,
position: index + 1,
user: userId,
timeline,
})),
);
}
export async function upsertSavedAlbums(userId: string, saved: SavedAlbum[]) {
if (saved.length === 0) return;
const albums = saved.map((item) => item.album);
await upsertAlbums(albums);
const albumIdMap = await getAlbumIdMap(albums.map((t) => t.id));
await db.insert(savedAlbum).values(
saved.map((item) => ({
album: albumIdMap.get(item.album.id)!,
user: userId,
saved_at: new Date(item.added_at),
})),
);
}
export async function upsertSavedTracks(userId: string, saved: SavedTrack[]) {
if (saved.length === 0) return;
const tracks = saved.map((item) => item.track);
await upsertTracks(tracks);
const trackIdMap = await getTrackIdMap(tracks.map((t) => t.id));
await db.insert(savedTrack).values(
saved.map((item) => ({
track: trackIdMap.get(item.track.id)!,
user: userId,
saved_at: new Date(item.added_at),
})),
);
}
export async function upsertFollowedArtists(
userId: string,
followed: FollowedArtists,
) {
const artists = followed.artists.items;
if (artists.length === 0) return;
await upsertArtists(artists);
const artistIdMap = await getArtistIdMap(artists.map((t) => t.id));
await db.insert(followedArtist).values(
artists.map((spotifyArtist) => ({
artist: artistIdMap.get(spotifyArtist.id)!,
user: userId,
})),
);
}
export async function upsertPlaybackHistory(
userId: string,
items: PlayHistory[],
) {
if (items.length === 0) return;
const tracks = items.map((item) => item.track);
await upsertTracks(tracks);
const trackIdMap = await getTrackIdMap(tracks.map((t) => t.id));
await db.insert(playbackHistory).values(
items.map((item) => ({
track: trackIdMap.get(item.track.id)!,
user: userId,
played_at: new Date(item.played_at),
})),
);
}

View file

@ -1,6 +1,14 @@
import Elysia from "elysia";
import { auth, betterAuthElysia, SPOTIFY_CLIENT_ID } from "../auth";
import { SpotifyApi } from "@spotify/web-api-ts-sdk";
import {
upsertFollowedArtists,
upsertPlaybackHistory,
upsertSavedAlbums,
upsertSavedTracks,
upsertTopArtists,
upsertTopTracks,
} from "../db/spotify";
export const syncApp = new Elysia().use(betterAuthElysia).post(
"/sync",
@ -28,8 +36,25 @@ export const syncApp = new Elysia().use(betterAuthElysia).post(
timeline,
50,
);
const topTracks = await sdk.currentUser.topItems("tracks", timeline, 50);
await upsertTopArtists(user.id, timeline, topArtists.items);
}
for (const timeline of [
"short_term",
"medium_term",
"long_term",
] as const) {
const topTracks = await sdk.currentUser.topItems("tracks", timeline, 50);
await upsertTopTracks(user.id, timeline, topTracks.items);
}
const followedArtists = await sdk.currentUser.followedArtists();
await upsertFollowedArtists(user.id, followedArtists);
const savedAlbums = await sdk.currentUser.albums.savedAlbums(50);
await upsertSavedAlbums(user.id, savedAlbums.items);
const savedTracks = await sdk.currentUser.tracks.savedTracks(50);
await upsertSavedTracks(user.id, savedTracks.items);
const recentlyPlayed = await sdk.player.getRecentlyPlayedTracks(50);
await upsertPlaybackHistory(user.id, recentlyPlayed.items);
return { ok: true };
},
{
auth: true,