504 lines
12 KiB
TypeScript
504 lines
12 KiB
TypeScript
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<typeof db.transaction>[0] extends (
|
|
tx: infer T,
|
|
) => Promise<unknown>
|
|
? T
|
|
: never;
|
|
type DbLike = DbClient | DbTransaction;
|
|
|
|
const requireMapEntry = <K, V>(map: Map<K, V>, 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<string, string>();
|
|
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<string, string>();
|
|
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<string, string>();
|
|
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();
|
|
}
|