diff --git a/api/src/auth.ts b/api/src/auth.ts index 19af106..c1c1a2a 100644 --- a/api/src/auth.ts +++ b/api/src/auth.ts @@ -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", diff --git a/api/src/db/index.ts b/api/src/db/index.ts index 1e58183..f89c2b9 100644 --- a/api/src/db/index.ts +++ b/api/src/db/index.ts @@ -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 }); diff --git a/api/src/db/schema.ts b/api/src/db/schema.ts index ec59a44..435f5d8 100644 --- a/api/src/db/schema.ts +++ b/api/src/db/schema.ts @@ -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, + }), + }, + }), +); diff --git a/api/src/db/spotify.ts b/api/src/db/spotify.ts new file mode 100644 index 0000000..5616f42 --- /dev/null +++ b/api/src/db/spotify.ts @@ -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(); + 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(); + 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(); + 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), + })), + ); +} diff --git a/api/src/routes/sync.ts b/api/src/routes/sync.ts index 0a8fa2d..a8ca0d4 100644 --- a/api/src/routes/sync.ts +++ b/api/src/routes/sync.ts @@ -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,