import type { Album, Artist, Image, PlayHistory, SavedAlbum, SavedTrack, SimplifiedAlbum, SimplifiedArtist, Track, } from "@spotify/web-api-ts-sdk"; import { and, eq, inArray, sql } from "drizzle-orm"; import { defaultSdk } from "../auth"; import { db } from "."; import { album, albumArtist, albumGenre, albumImage, artist, artistGenre, artistImage, followedArtist, genre, platformImage, playbackHistory, savedAlbum, savedTrack, topArtist, topTrack, track, trackArtist, } from "./schema"; export const PLATFORM_SPOTIFY = "spotify" as const; type DbClient = typeof db; type DbTransaction = Parameters[0] extends ( tx: infer T, ) => Promise ? T : never; type DbLike = DbClient | DbTransaction; const requireMapEntry = (map: Map, key: K, label: string): V => { const value = map.get(key); if (!value) throw new Error(`Missing ${label} for ${String(key)}`); return value; }; export async function upsertImages(images: Image[], dbClient: DbLike = db) { await dbClient .insert(platformImage) .values( images.map(({ url, height, width }) => ({ platform: PLATFORM_SPOTIFY, url, height, width, })), ) .onConflictDoNothing(); } export async function upsertGenres(genres: string[], dbClient: DbLike = db) { const values = genres.filter(Boolean).map((name) => ({ name })); if (values.length === 0) return; await dbClient.insert(genre).values(values).onConflictDoNothing(); } export async function upsertArtists(artists: Artist[], dbClient: DbLike = db) { await dbClient .insert(artist) .values( artists.map(({ id, name, popularity, type }) => ({ platform: PLATFORM_SPOTIFY, platform_id: id, name, popularity, type, })), ) .onConflictDoNothing(); await upsertImages( artists.flatMap((a) => a.images), dbClient, ); await upsertGenres( artists.flatMap((a) => a.genres), dbClient, ); for (const spotifyArtist of artists) { await dbClient .insert(artistImage) .select( dbClient .select({ artistId: artist.id, imageId: platformImage.id, }) .from(platformImage) .where( and( eq(platformImage.platform, PLATFORM_SPOTIFY), inArray( platformImage.url, spotifyArtist.images.map((t) => t.url), ), ), ) .innerJoin(artist, eq(artist.platform_id, spotifyArtist.id)), ) .onConflictDoNothing(); await dbClient .insert(artistGenre) .select( dbClient .select({ artistId: artist.id, genreId: genre.id, }) .from(genre) .where(inArray(genre.name, spotifyArtist.genres)) .innerJoin(artist, eq(artist.platform_id, spotifyArtist.id)), ) .onConflictDoNothing(); } } async function getMissingArtists(artistIds: string[], dbClient: DbLike = db) { const existingArtists = await dbClient .select({ id: artist.id }) .from(artist) .where(inArray(artist.platform_id, artistIds)); return artistIds.filter((id) => !existingArtists.some((a) => a.id === id)); } async function lookupMissingArtists( artistIds: string[], dbClient: DbLike = db, ) { const missingArtistIds = await getMissingArtists(artistIds, dbClient); if (missingArtistIds.length === 0) return []; const missingArtists: Artist[] = []; for (let i = 0; i < missingArtistIds.length / 50; i++) { missingArtists.push( ...(await defaultSdk.artists.get( missingArtistIds.slice(i * 50, (i + 1) * 50), )), ); } await upsertArtists(missingArtists, dbClient); return missingArtists; } function isFullArtistArray( artists: Artist[] | SimplifiedArtist[], ): artists is Artist[] { const firstArtist = artists[0]; if (!firstArtist) return false; return "images" in firstArtist; } async function upsertMissingArtists( artists: SimplifiedArtist[] | Artist[], dbClient: DbLike = db, ) { if (artists.length === 0) return; let missingArtists: Artist[]; if (isFullArtistArray(artists)) { const missingArtistIds = await getMissingArtists( artists.map((t) => t.id), dbClient, ); if (missingArtistIds.length === 0) return; missingArtists = artists.filter((a) => missingArtistIds.includes(a.id)); } else { missingArtists = await lookupMissingArtists( artists.map((t) => t.id), dbClient, ); } if (missingArtists.length === 0) return; await upsertArtists(missingArtists, dbClient); return missingArtists; } async function getArtistIdMap(artistIds: string[], dbClient: DbLike = db) { if (artistIds.length === 0) return new Map(); const rows = await dbClient .select({ id: artist.id, platform_id: artist.platform_id }) .from(artist) .where(inArray(artist.platform_id, artistIds)); return new Map(rows.map((row) => [row.platform_id, row.id])); } async function getAlbumIdMap(albumIds: string[], dbClient: DbLike = db) { if (albumIds.length === 0) return new Map(); const rows = await dbClient .select({ id: album.id, platform_id: album.platform_id }) .from(album) .where(inArray(album.platform_id, albumIds)); return new Map( rows .filter((row) => row.platform_id) .map((row) => [row.platform_id as string, row.id]), ); } async function getTrackIdMap(trackIds: string[], dbClient: DbLike = db) { if (trackIds.length === 0) return new Map(); const rows = await dbClient .select({ id: track.id, platform_id: track.platform_id }) .from(track) .where(inArray(track.platform_id, trackIds)); return new Map( rows .filter((row) => row.platform_id) .map((row) => [row.platform_id as string, row.id]), ); } export async function upsertAlbums( albums: Album[] | SimplifiedAlbum[], dbClient: DbLike = db, ) { await upsertMissingArtists( albums.flatMap((a) => a.artists), dbClient, ); await dbClient .insert(album) .values( albums.map(({ id, name, type, popularity, release_date, label }) => ({ platform: PLATFORM_SPOTIFY, platform_id: id, name, type, popularity, release_date: new Date(release_date), label, })), ) .onConflictDoNothing(); await upsertImages( albums.flatMap((a) => a.images), dbClient, ); await upsertGenres( albums.flatMap((a) => a.genres), dbClient, ); for (const spotifyAlbum of albums) { await dbClient .insert(albumImage) .select( dbClient .select({ albumId: album.id, imageId: platformImage.id, }) .from(platformImage) .where( and( eq(platformImage.platform, PLATFORM_SPOTIFY), inArray( platformImage.url, spotifyAlbum.images.map((t) => t.url), ), ), ) .innerJoin(album, eq(album.platform_id, spotifyAlbum.id)), ) .onConflictDoNothing(); await dbClient .insert(albumArtist) .select( dbClient .select({ albumId: album.id, artistId: artist.id, }) .from(artist) .where( inArray( artist.platform_id, spotifyAlbum.artists.map((t) => t.id), ), ) .innerJoin(album, eq(album.platform_id, spotifyAlbum.id)), ) .onConflictDoNothing(); if (spotifyAlbum.genres?.length > 0) await dbClient .insert(albumGenre) .select( dbClient .select({ albumId: album.id, genreId: genre.id, }) .from(genre) .where(inArray(genre.name, sql`${spotifyAlbum.genres}`)) .innerJoin(album, eq(album.platform_id, spotifyAlbum.id)), ) .onConflictDoNothing(); } } export async function upsertTracks(tracks: Track[], dbClient: DbLike = db) { if (tracks.length === 0) return; await upsertAlbums( tracks.map((t) => t.album), dbClient, ); await upsertMissingArtists( tracks.flatMap((t) => t.artists), dbClient, ); const albumIdMap = await getAlbumIdMap( tracks.map((t) => t.album.id), dbClient, ); await dbClient .insert(track) .values( tracks.map((spotifyTrack) => ({ albumId: requireMapEntry(albumIdMap, spotifyTrack.album.id, "albumId"), name: spotifyTrack.name, platform: PLATFORM_SPOTIFY, platform_id: spotifyTrack.id, popularity: spotifyTrack.popularity, duration: spotifyTrack.duration_ms, explicit: spotifyTrack.explicit, disc_number: spotifyTrack.disc_number, track_number: spotifyTrack.track_number, })), ) .onConflictDoNothing(); for (const spotifyTrack of tracks) { await dbClient .insert(trackArtist) .select( dbClient .select({ trackId: track.id, artistId: artist.id, }) .from(artist) .where( inArray( artist.platform_id, spotifyTrack.artists.map((t) => t.id), ), ) .innerJoin(track, eq(track.platform_id, spotifyTrack.id)), ) .onConflictDoNothing(); } } export async function upsertTopArtists( userId: string, timeline: "short_term" | "medium_term" | "long_term", artists: Artist[], dbClient: DbLike = db, ) { if (artists.length === 0) return; await upsertArtists(artists, dbClient); const artistIdMap = await getArtistIdMap( artists.map((t) => t.id), dbClient, ); await dbClient .insert(topArtist) .values( artists.map((spotifyArtist, index) => ({ artistId: requireMapEntry(artistIdMap, spotifyArtist.id, "artistId"), position: index + 1, userId, timeline, })), ) .onConflictDoNothing(); } export async function upsertTopTracks( userId: string, timeline: "short_term" | "medium_term" | "long_term", tracks: Track[], dbClient: DbLike = db, ) { if (tracks.length === 0) return; await upsertTracks(tracks, dbClient); const trackIdMap = await getTrackIdMap( tracks.map((t) => t.id), dbClient, ); await dbClient .insert(topTrack) .values( tracks.map((spotifyTrack, index) => ({ trackId: requireMapEntry(trackIdMap, spotifyTrack.id, "trackId"), position: index + 1, userId, timeline, })), ) .onConflictDoNothing(); } export async function upsertSavedAlbums( userId: string, saved: SavedAlbum[], dbClient: DbLike = db, ) { if (saved.length === 0) return; const albums = saved.map((item) => item.album); await upsertAlbums(albums, dbClient); const albumIdMap = await getAlbumIdMap( albums.map((t) => t.id), dbClient, ); await dbClient .insert(savedAlbum) .values( saved.map((item) => ({ albumId: requireMapEntry(albumIdMap, item.album.id, "albumId"), userId, saved_at: new Date(item.added_at), })), ) .onConflictDoNothing(); } export async function upsertSavedTracks( userId: string, saved: SavedTrack[], dbClient: DbLike = db, ) { if (saved.length === 0) return; const tracks = saved.map((item) => item.track); await upsertTracks(tracks, dbClient); const trackIdMap = await getTrackIdMap( tracks.map((t) => t.id), dbClient, ); await dbClient .insert(savedTrack) .values( saved.map((item) => ({ trackId: requireMapEntry(trackIdMap, item.track.id, "trackId"), userId, saved_at: new Date(item.added_at), })), ) .onConflictDoNothing(); } export async function upsertFollowedArtists( userId: string, artists: Artist[], dbClient: DbLike = db, ) { if (artists.length === 0) return; await upsertArtists(artists, dbClient); const artistIdMap = await getArtistIdMap( artists.map((t) => t.id), dbClient, ); await dbClient .insert(followedArtist) .values( artists.map((spotifyArtist) => ({ artistId: requireMapEntry(artistIdMap, spotifyArtist.id, "artistId"), userId, })), ) .onConflictDoNothing(); } export async function upsertPlaybackHistory( userId: string, items: PlayHistory[], dbClient: DbLike = db, ) { if (items.length === 0) return; const tracks = items.map((item) => item.track); await upsertTracks(tracks, dbClient); const trackIdMap = await getTrackIdMap( tracks.map((t) => t.id), dbClient, ); await dbClient .insert(playbackHistory) .values( items.map((item) => ({ trackId: requireMapEntry(trackIdMap, item.track.id, "trackId"), userId, played_at: new Date(item.played_at), })), ) .onConflictDoNothing(); }