import { and, eq, inArray } from "drizzle-orm"; import type { db } from "../db"; import { topArtist as topArtistTable, topTrack as topTrackTable, } from "../db/schema"; import type { Question, QuizRound } from "../party-types"; import { buildQuestionWindow, getReleaseYearRange, isUsableText, type PartyAnalytics, type PartyQuestionMember, pickQuestionCandidate, type QuestionCandidate, resolveQuestionSong, } from "./question-utils"; type NumericQuestion = Omit< Extract, "startTimestamp" | "endTimestamp" >; type TrackDetails = { id: string; name: string | null; platform_id: string | null; album?: { name: string | null; release_date: Date | null } | null; artists?: { name: string }[] | null; }; type BuildNumericQuestionInput = { db: typeof db; analytics: PartyAnalytics; index: number; members: PartyQuestionMember[]; history: QuizRound[]; }; async function getDetailedTopTracks({ db, analytics, }: BuildNumericQuestionInput): Promise { const tracks: TrackDetails[] = []; const seen = new Set(); for (const topTrack of analytics?.storyClusters?.flatMap( (cluster) => cluster.tracks ?? [], ) ?? []) { if (!isUsableText(topTrack.name)) continue; const dbTracks = (await db.query.track.findMany({ where: { name: topTrack.name }, with: { album: true, artists: true }, })) as TrackDetails[]; const topArtists = topTrack.artists?.map((artist) => artist.name) ?? []; const track = dbTracks.slice().sort((a, b) => { const score = (candidate: typeof a) => { const artistNames = candidate.artists?.map((artist) => artist.name) ?? []; return ( (candidate.album?.name === topTrack.albumName ? 2 : 0) + (topArtists.some((name) => artistNames.includes(name)) ? 2 : 0) ); }; return score(b) - score(a); })[0]; if (!track || !isUsableText(track.name)) continue; const key = track.platform_id ?? track.id; if (seen.has(key)) continue; seen.add(key); tracks.push(track); } return tracks; } async function getAlbumReleaseYear({ db, analytics, }: BuildNumericQuestionInput): Promise { const trackName = analytics?.storyClusters?.[0]?.tracks?.[0]?.name; const track = trackName ? await db.query.track.findFirst({ where: { name: trackName }, with: { album: true }, }) : null; const subject = [track?.album?.name, track?.name].find((value) => isUsableText(value), ); if (!subject) return null; if (!track?.album?.release_date) return null; const song = await resolveQuestionSong(db, analytics, { trackName: track?.name ?? trackName ?? undefined, }); const correct = track.album.release_date.getFullYear(); return { type: "numeric", text: `What's the release year of ${subject}?`, correct, range: getReleaseYearRange(correct), points: 10, song: song ?? undefined, questionKey: `numeric:album-year:${subject}`, subjectKey: `album:${subject}`, }; } async function getTrackReleaseYear( input: BuildNumericQuestionInput, ): Promise { const tracks = await getDetailedTopTracks(input); const track = tracks.find((track) => track.album?.release_date && track.name); if (!track?.name || !track.album?.release_date) return null; const song = await resolveQuestionSong(input.db, input.analytics, { trackName: track.name, artistNames: track.artists?.map((artist) => artist.name), albumName: track.album?.name ?? undefined, }); const correct = track.album.release_date.getFullYear(); return { type: "numeric", text: `What year did "${track.name}" come out?`, correct, range: getReleaseYearRange(correct), points: 10, song: song ?? undefined, questionKey: `numeric:track-year:${track.name}`, subjectKey: `track:${track.name}`, }; } async function getArtistFirstTrackReleaseYear( input: BuildNumericQuestionInput, ): Promise { const tracks = await getDetailedTopTracks(input); const tracksByArtist = new Map(); for (const track of tracks) { if (!track.album?.release_date) continue; for (const artist of track.artists ?? []) { if (!isUsableText(artist.name)) continue; const artistTracks = tracksByArtist.get(artist.name) ?? []; artistTracks.push(track); tracksByArtist.set(artist.name, artistTracks); } } const artistEntry = Array.from(tracksByArtist.entries()).find( ([, artistTracks]) => artistTracks.length >= 2, ); if (!artistEntry) return null; const [artistName, artistTracks] = artistEntry; const firstTrack = artistTracks .slice() .sort( (a, b) => Number(a.album?.release_date ?? 0) - Number(b.album?.release_date ?? 0), )[0]; if (!firstTrack?.album?.release_date) return null; const song = await resolveQuestionSong(input.db, input.analytics, { trackName: firstTrack.name ?? undefined, artistNames: [artistName], albumName: firstTrack.album?.name ?? undefined, }); const correct = firstTrack.album.release_date.getFullYear(); return { type: "numeric", text: `What year did ${artistName}'s first party track come out?`, correct, range: getReleaseYearRange(correct), points: 10, song: song ?? undefined, questionKey: `numeric:artist-first-track-year:${artistName}`, subjectKey: `artist:${artistName}`, }; } async function countTopTrackListeners({ db, analytics, members, }: BuildNumericQuestionInput): Promise { const trackName = analytics?.storyClusters?.[0]?.tracks?.[0]?.name; if (!trackName || members.length === 0) return null; const dbTrack = await db.query.track.findFirst({ where: { name: trackName }, }); if (!dbTrack) return null; const song = await resolveQuestionSong(db, analytics, { trackName }); const memberIds = members.map((m) => m.userId); const entries = await db .select({ userId: topTrackTable.userId }) .from(topTrackTable) .where( and( eq(topTrackTable.trackId, dbTrack.id), inArray(topTrackTable.userId, memberIds), ), ); const correct = new Set(entries.map((e) => e.userId)).size; if (correct <= 0) return null; return { type: "numeric", text: `For how many players in the party is "${trackName}" a top track?`, correct, range: { min: 0, max: members.length }, points: 10, song: song ?? undefined, questionKey: `numeric:top-track-count:${trackName}`, subjectKey: `track:${trackName}`, }; } async function countFavouriteArtistListeners({ db, analytics, members, }: BuildNumericQuestionInput): Promise { const artistName = analytics?.storyClusters?.[0]?.artists?.[0]?.name; if (!artistName || members.length === 0) return null; const dbArtist = await db.query.artist.findFirst({ where: { name: artistName }, }); if (!dbArtist) return null; const song = await resolveQuestionSong(db, analytics, { artistNames: [artistName], }); const memberIds = members.map((m) => m.userId); const entries = await db .select({ userId: topArtistTable.userId }) .from(topArtistTable) .where( and( eq(topArtistTable.artistId, dbArtist.id), inArray(topArtistTable.userId, memberIds), ), ); const correct = new Set(entries.map((e) => e.userId)).size; if (correct <= 0) return null; return { type: "numeric", text: `How many players in the party have "${artistName}" as a favourite artist?`, correct, range: { min: 0, max: members.length }, points: 10, song: song ?? undefined, questionKey: `numeric:artist-count:${artistName}`, subjectKey: `artist:${artistName}`, }; } export async function buildNumericQuestion( input: BuildNumericQuestionInput, ): Promise { const questions: Array> = []; const albumYearQ = await getAlbumReleaseYear(input); if (albumYearQ) { questions.push({ key: albumYearQ.questionKey ?? `numeric:album-year:${albumYearQ.text}`, subjectKey: albumYearQ.subjectKey, question: albumYearQ, }); } const trackYearQ = await getTrackReleaseYear(input); if (trackYearQ) { questions.push({ key: trackYearQ.questionKey ?? `numeric:track-year:${trackYearQ.text}`, subjectKey: trackYearQ.subjectKey, question: trackYearQ, }); } const artistFirstTrackYearQ = await getArtistFirstTrackReleaseYear(input); if (artistFirstTrackYearQ) { questions.push({ key: artistFirstTrackYearQ.questionKey ?? `numeric:artist-first-track-year:${artistFirstTrackYearQ.text}`, subjectKey: artistFirstTrackYearQ.subjectKey, question: artistFirstTrackYearQ, }); } const topTrackQ = await countTopTrackListeners(input); if (topTrackQ) { questions.push({ key: topTrackQ.questionKey ?? `numeric:top-track-count:${topTrackQ.text}`, subjectKey: topTrackQ.subjectKey, question: topTrackQ, }); } const artistQ = await countFavouriteArtistListeners(input); if (artistQ) { questions.push({ key: artistQ.questionKey ?? `numeric:artist-count:${artistQ.text}`, subjectKey: artistQ.subjectKey, question: artistQ, }); } const question = pickQuestionCandidate(questions, input.history, input.index); if (!question) return null; return buildQuestionWindow(question); }