308 lines
8.9 KiB
TypeScript
308 lines
8.9 KiB
TypeScript
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<Question, { type: "numeric" }>,
|
|
"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<TrackDetails[]> {
|
|
const tracks: TrackDetails[] = [];
|
|
const seen = new Set<string>();
|
|
|
|
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<NumericQuestion | null> {
|
|
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<NumericQuestion | null> {
|
|
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<NumericQuestion | null> {
|
|
const tracks = await getDetailedTopTracks(input);
|
|
const tracksByArtist = new Map<string, TrackDetails[]>();
|
|
|
|
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<NumericQuestion | null> {
|
|
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<NumericQuestion | null> {
|
|
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<Question | null> {
|
|
const questions: Array<QuestionCandidate<NumericQuestion>> = [];
|
|
|
|
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);
|
|
}
|