itpdp/api/src/db/spotify.ts
2026-04-21 22:01:53 +02:00

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();
}