diff --git a/api/src/party-types.ts b/api/src/party-types.ts index 9962ae5..d889b31 100644 --- a/api/src/party-types.ts +++ b/api/src/party-types.ts @@ -1,11 +1,12 @@ import type { InferSelectModel } from "drizzle-orm"; -import type { party, partyMember, user } from "./db/schema"; +import type { party, partyMember, track, user } from "./db/schema"; export type Party = Omit, "data"> & { data: QuizState; }; export type PartyMember = InferSelectModel; export type User = InferSelectModel; +export type Song = InferSelectModel; export type PartyMemberWithUser = PartyMember & { user: User | null }; @@ -33,6 +34,7 @@ type BaseQuestion = { startTimestamp: number; endTimestamp: number; points: number; + song?: Song; }; export type Question = diff --git a/api/src/party/audio-question-generator.ts b/api/src/party/audio-question-generator.ts index 573588d..67103ef 100644 --- a/api/src/party/audio-question-generator.ts +++ b/api/src/party/audio-question-generator.ts @@ -1,3 +1,4 @@ +import type { db as Db } from "../db"; import type { Question } from "../party-types"; import { buildOptionsWithCorrect, @@ -8,16 +9,19 @@ import { getTopClusterTracks, type PartyAnalytics, pickRandom, + resolveQuestionSong, } from "./question-utils"; -export function buildAudioMetadataQuestion( +export async function buildAudioMetadataQuestion( + dbClient: typeof Db, analytics: PartyAnalytics, index: number, -): Question | null { +): Promise { type ChoiceQuestion = Extract; const questions: Array< Omit > = []; + const topSong = await resolveQuestionSong(dbClient, analytics); const genreOptions = buildOrderedOptions( getMostSharedGenreNames(analytics), @@ -30,6 +34,7 @@ export function buildAudioMetadataQuestion( options: genreOptions, correct: 0, points: 10, + song: topSong ?? undefined, }); } @@ -44,6 +49,7 @@ export function buildAudioMetadataQuestion( options: artistOptions, correct: 0, points: 10, + song: topSong ?? undefined, }); } } @@ -62,12 +68,18 @@ export function buildAudioMetadataQuestion( options: trackOptions, correct: 0, points: 10, + song: topSong ?? undefined, }); } } const randomTopTrack = pickRandom(topTracks); if (randomTopTrack) { + const randomTrackSong = await resolveQuestionSong(dbClient, analytics, { + trackName: randomTopTrack.name, + artistNames: randomTopTrack.artists?.map((artist) => artist.name), + albumName: randomTopTrack.albumName, + }); const trackArtists = randomTopTrack.artists?.map((artist) => artist.name) ?? []; const allArtists = topArtists.length > 0 ? topArtists : trackArtists; @@ -85,6 +97,7 @@ export function buildAudioMetadataQuestion( options: artistOptions, correct: 0, points: 10, + song: randomTrackSong ?? topSong ?? undefined, }); } @@ -101,6 +114,7 @@ export function buildAudioMetadataQuestion( options: trackNameOptions, correct: 0, points: 10, + song: randomTrackSong ?? topSong ?? undefined, }); } } @@ -121,13 +135,14 @@ export function buildAudioMetadataQuestion( options: albumOptions, correct: 0, points: 10, + song: randomTrackSong ?? topSong ?? undefined, }); } } } - if (questions.length === 0) { - return null + if (questions.length === 0) { + return null; } const question = questions[index % questions.length]; diff --git a/api/src/party/numeric-question-generator.ts b/api/src/party/numeric-question-generator.ts index 8c61e15..2f50fdf 100644 --- a/api/src/party/numeric-question-generator.ts +++ b/api/src/party/numeric-question-generator.ts @@ -10,6 +10,7 @@ import { getQuestionRange, type PartyAnalytics, type PartyQuestionMember, + resolveQuestionSong, } from "./question-utils"; type NumericQuestion = Omit< @@ -36,6 +37,9 @@ async function getAlbumReleaseYear({ with: { album: true }, }) : null; + const song = await resolveQuestionSong(db, analytics, { + trackName: track?.name ?? trackName ?? undefined, + }); const correct = track?.album?.release_date?.getFullYear() ?? new Date().getFullYear() - 1 - index; @@ -46,6 +50,7 @@ async function getAlbumReleaseYear({ correct, range: getQuestionRange(correct, 10, 3), points: 10, + song: song ?? undefined, }; } @@ -60,6 +65,7 @@ async function countTopTrackListeners({ 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 }) @@ -77,6 +83,7 @@ async function countTopTrackListeners({ correct, range: { min: 0, max: members.length }, points: 10, + song: song ?? undefined, }; } @@ -91,6 +98,9 @@ async function countFavouriteArtistListeners({ 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 }) @@ -108,12 +118,13 @@ async function countFavouriteArtistListeners({ correct, range: { min: 0, max: members.length }, points: 10, + song: song ?? undefined, }; } export async function buildNumericQuestion( input: BuildNumericQuestionInput, -): Promise { +): Promise { const questions: NumericQuestion[] = []; questions.push(await getAlbumReleaseYear(input)); diff --git a/api/src/party/question-generator.ts b/api/src/party/question-generator.ts index f6a7d86..08dd9fc 100644 --- a/api/src/party/question-generator.ts +++ b/api/src/party/question-generator.ts @@ -27,13 +27,18 @@ export async function generatePartyQuestion({ const type: PartyQuestionType = index === 2 ? "numeric" : index % 2 === 0 ? "audio-metadata" : "social"; - if (type === "audio-metadata") { - let q = buildAudioMetadataQuestion(analytics, index); - if (q) return q; - } - if (type === "numeric") { - let q = await buildNumericQuestion({ db: dbClient, analytics, index, members }); - if (q) return q; - } - return buildSocialQuestion(quizState, analytics, members, index); + if (type === "audio-metadata") { + const q = await buildAudioMetadataQuestion(dbClient, analytics, index); + if (q) return q; + } + if (type === "numeric") { + const q = await buildNumericQuestion({ + db: dbClient, + analytics, + index, + members, + }); + if (q) return q; + } + return buildSocialQuestion(dbClient, quizState, analytics, members, index); } diff --git a/api/src/party/question-utils.ts b/api/src/party/question-utils.ts index 1797ea3..a9eaee8 100644 --- a/api/src/party/question-utils.ts +++ b/api/src/party/question-utils.ts @@ -1,4 +1,6 @@ +import type { InferSelectModel } from "drizzle-orm"; import type { db as Db } from "../db"; +import type { track as trackTable } from "../db/schema"; export type PartyQuestionMember = { userId: string; @@ -23,6 +25,14 @@ export type PartyAnalytics = { pairwise?: { userIdA: string; userIdB: string }[]; } | null; +export type AnalyticsTrack = { + name: string; + artists?: { name: string }[]; + albumName?: string; + memberScores?: { userId: string; score: number }[]; +}; +export type QuestionSong = InferSelectModel; + export const QUESTION_DURATION_MS = 60_000; export const MIN_PARTY_SIZE = 2; export const MAX_PARTY_SIZE = 4; @@ -100,15 +110,80 @@ export function getTopClusterArtists(analytics: PartyAnalytics): string[] { ); } -export function getTopClusterTracks(analytics: PartyAnalytics): Array<{ - name: string; - artists?: { name: string }[]; - albumName?: string; - memberScores?: { userId: string; score: number }[]; -}> { +export function getTopClusterTracks( + analytics: PartyAnalytics, +): AnalyticsTrack[] { return analytics?.storyClusters?.[0]?.tracks ?? []; } +export function pickRelevantTrack( + analytics: PartyAnalytics, + hints: { + trackName?: string; + artistNames?: string[]; + albumName?: string; + } = {}, +): AnalyticsTrack | null { + const tracks = getTopClusterTracks(analytics); + if (tracks.length === 0) return null; + + const scored = tracks.map((track) => { + const trackArtistNames = track.artists?.map((artist) => artist.name) ?? []; + const matchesTrackName = hints.trackName && track.name === hints.trackName; + const matchesAlbum = hints.albumName && track.albumName === hints.albumName; + const matchesArtist = + hints.artistNames?.some((name) => trackArtistNames.includes(name)) ?? + false; + + return { + track, + score: + (matchesTrackName ? 4 : 0) + + (matchesAlbum ? 2 : 0) + + (matchesArtist ? 2 : 0), + }; + }); + + const best = scored.sort((a, b) => b.score - a.score).at(0); + return best?.track ?? tracks[0] ?? null; +} + +export async function resolveQuestionSong( + db: typeof Db, + analytics: PartyAnalytics, + hints: { + trackName?: string; + artistNames?: string[]; + albumName?: string; + } = {}, +): Promise { + const trackHint = pickRelevantTrack(analytics, hints); + if (!trackHint?.name) return null; + + const tracks = await db.query.track.findMany({ + where: { name: trackHint.name }, + with: { album: true, artists: true }, + }); + if (tracks.length === 0) return null; + + const scoreTrack = (track: (typeof tracks)[number]) => { + const artistNames = track.artists?.map((artist) => artist.name) ?? []; + const matchesAlbum = trackHint.albumName + ? track.album?.name === trackHint.albumName + : false; + const matchesArtist = + hints.artistNames?.some((name) => artistNames.includes(name)) ?? false; + return (matchesAlbum ? 2 : 0) + (matchesArtist ? 2 : 0); + }; + + const chosen = tracks + .slice() + .sort((a, b) => scoreTrack(b) - scoreTrack(a))[0]; + if (!chosen) return null; + const { album: _album, artists: _artists, ...song } = chosen; + return song; +} + export function getTopTrackListener( track: { memberScores?: { userId: string; score: number }[] }, members: PartyQuestionMember[], diff --git a/api/src/party/social-question-generator.ts b/api/src/party/social-question-generator.ts index b039fd7..9c9c200 100644 --- a/api/src/party/social-question-generator.ts +++ b/api/src/party/social-question-generator.ts @@ -1,3 +1,4 @@ +import type { db as Db } from "../db"; import type { Question, QuizState } from "../party-types"; import { buildMemberOptions, @@ -12,18 +13,21 @@ import { type PartyAnalytics, type PartyQuestionMember, pickRandom, + resolveQuestionSong, } from "./question-utils"; -export function buildSocialQuestion( +export async function buildSocialQuestion( + dbClient: typeof Db, quizState: QuizState, analytics: PartyAnalytics, members: PartyQuestionMember[], index: number, -): Question | null { +): Promise { type ChoiceQuestion = Extract; const questions: Array< Omit > = []; + const topSong = await resolveQuestionSong(dbClient, analytics); const hasMultipleMembers = members.length >= 2; if (hasMultipleMembers && hasClearLeader(quizState)) { @@ -34,6 +38,7 @@ export function buildSocialQuestion( options: buildMemberOptions(leader, members), correct: 0, points: 10, + song: topSong ?? undefined, }); } @@ -45,6 +50,7 @@ export function buildSocialQuestion( options: buildMemberOptions(diverse, members), correct: 0, points: 10, + song: topSong ?? undefined, }); const aligned = getMostAlignedMember(analytics, members); @@ -54,6 +60,7 @@ export function buildSocialQuestion( options: buildMemberOptions(aligned, members), correct: 0, points: 10, + song: topSong ?? undefined, }); } @@ -62,12 +69,18 @@ export function buildSocialQuestion( if (randomTrack && hasMultipleMembers) { const topListener = getTopTrackListener(randomTrack, members); if (topListener) { + const randomTrackSong = await resolveQuestionSong(dbClient, analytics, { + trackName: randomTrack.name, + artistNames: randomTrack.artists?.map((artist) => artist.name), + albumName: randomTrack.albumName, + }); questions.push({ type: "choice", text: `Who listens the most to "${randomTrack.name}"?`, options: buildMemberOptions(topListener, members), correct: 0, points: 10, + song: randomTrackSong ?? topSong ?? undefined, }); questions.push({ type: "choice", @@ -75,6 +88,7 @@ export function buildSocialQuestion( options: buildMemberOptions(topListener, members), correct: 0, points: 10, + song: randomTrackSong ?? topSong ?? undefined, }); } } @@ -93,13 +107,14 @@ export function buildSocialQuestion( options: pairOptions, correct: 0, points: 10, + song: topSong ?? undefined, }); } } } - if (questions.length === 0) { - return null + if (questions.length === 0) { + return null; } const question = questions[index % questions.length];