diff --git a/api/src/party/__tests__/question-generation.test.ts b/api/src/party/__tests__/question-generation.test.ts index 60691e5..b2662bd 100644 --- a/api/src/party/__tests__/question-generation.test.ts +++ b/api/src/party/__tests__/question-generation.test.ts @@ -64,6 +64,61 @@ function createFakeDb(trackReleaseDate: Date | null) { } as unknown as Db; } +function createDetailedTrackDb() { + const tracks = [ + { + id: "track-1", + albumId: "album-1", + platform: "spotify", + platform_id: "spotify:track:one", + name: "First Track", + popularity: 1, + duration: 180000, + explicit: false, + disc_number: 1, + track_number: 1, + album: { name: "First Album", release_date: new Date("2001-01-01") }, + artists: [{ id: "artist-1", name: "Shared Artist" }], + }, + { + id: "track-2", + albumId: "album-2", + platform: "spotify", + platform_id: "spotify:track:two", + name: "Second Track", + popularity: 1, + duration: 240000, + explicit: false, + disc_number: 1, + track_number: 1, + album: { name: "Second Album", release_date: new Date("2010-01-01") }, + artists: [{ id: "artist-1", name: "Shared Artist" }], + }, + ]; + + return { + query: { + track: { + findFirst: vi.fn(async () => tracks[0]), + findMany: vi.fn(async ({ where }: { where?: { name?: string } } = {}) => + tracks.filter((track) => !where?.name || track.name === where.name), + ), + }, + artist: { + findFirst: vi.fn(async () => ({ + id: "artist-1", + name: "Shared Artist", + })), + }, + }, + select: vi.fn(() => ({ + from: () => ({ + where: async () => [], + }), + })), + } as unknown as Db; +} + function makeSong(id: string, platformId: string, name: string): Song { return { id, @@ -234,6 +289,66 @@ describe("question generation", () => { expect(question).toBeNull(); }); + it("builds a numeric release-year question for a track", async () => { + const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0); + const analytics = { + storyClusters: [ + { + tracks: [ + { + name: "First Track", + artists: [{ name: "Shared Artist" }], + albumName: "First Album", + }, + { + name: "Second Track", + artists: [{ name: "Shared Artist" }], + albumName: "Second Album", + }, + ], + artists: [{ name: "Shared Artist" }], + genres: [], + }, + ], + groupSummary: { mostSharedGenres: [] }, + } as PartyAnalytics; + + try { + const question = await buildNumericQuestion({ + db: createDetailedTrackDb(), + analytics, + index: 0, + members: [ + { userId: "a", name: "A" }, + { userId: "b", name: "B" }, + ], + history: [ + { + questionIndex: 0, + question: { + type: "numeric", + text: "What's the release year of First Album?", + correct: 2001, + startTimestamp: 1, + endTimestamp: 2, + points: 10, + range: { min: 1991, max: 2011 }, + questionKey: "numeric:album-year:First Album", + subjectKey: "album:First Album", + }, + responses: [], + }, + ], + }); + + expect(question?.text).toBe('What year did "First Track" come out?'); + expect(question?.correct).toBe(2001); + expect(question?.questionKey).toBe("numeric:track-year:First Track"); + } finally { + randomSpy.mockRestore(); + } + }); + it("skips social fallback names for duplicate members", async () => { const db = createFakeDb(null); const members: PartyQuestionMember[] = [ diff --git a/api/src/party/audio-question-generator.ts b/api/src/party/audio-question-generator.ts index f8ae5f3..e14c43b 100644 --- a/api/src/party/audio-question-generator.ts +++ b/api/src/party/audio-question-generator.ts @@ -14,6 +14,47 @@ import { resolveQuestionSong, } from "./question-utils"; +type TrackDetails = { + id: string; + name: string | null; + platform_id: string | null; + duration: number | null; + album?: { name: string | null; release_date: Date | null } | null; + artists?: { name: string }[] | null; +}; + +async function getDetailedTopTracks( + dbClient: typeof Db, + analytics: PartyAnalytics, +): Promise { + const tracks: TrackDetails[] = []; + const seen = new Set(); + + for (const topTrack of getTopClusterTracks(analytics)) { + const dbTracks = (await dbClient.query.track.findMany({ + where: { name: topTrack.name }, + with: { album: true, artists: true }, + })) as TrackDetails[]; + const sortedTracks = dbTracks.slice().sort((a, b) => { + const aArtists = a.artists?.map((artist) => artist.name) ?? []; + const bArtists = b.artists?.map((artist) => artist.name) ?? []; + const topArtists = topTrack.artists?.map((artist) => artist.name) ?? []; + const score = (track: typeof a, artistNames: string[]) => + (track.album?.name === topTrack.albumName ? 2 : 0) + + (topArtists.some((name) => artistNames.includes(name)) ? 2 : 0); + return score(b, bArtists) - score(a, aArtists); + }); + const track = sortedTracks[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; +} + export async function buildAudioMetadataQuestion( dbClient: typeof Db, analytics: PartyAnalytics, @@ -114,6 +155,120 @@ export async function buildAudioMetadataQuestion( } } + for (const topTrack of topTracks) { + const correctArtist = topTrack.artists?.find((artist) => + isUsableText(artist.name), + )?.name; + if (!topTrack.albumName || !correctArtist) continue; + const artistOptions = buildOptionsWithCorrect(correctArtist, topArtists, 4); + if (artistOptions) { + questions.push({ + key: `audio:album-artist:${topTrack.albumName}:${correctArtist}`, + subjectKey: `album:${topTrack.albumName}`, + question: { + type: "choice", + text: `Which artist appears on "${topTrack.albumName}"?`, + options: artistOptions, + correct: 0, + points: 10, + song: topSong ?? undefined, + }, + }); + } + } + + const detailedTracks = await getDetailedTopTracks(dbClient, analytics); + const datedTracks = detailedTracks.filter( + (track) => track.album?.release_date, + ); + if (datedTracks.length >= 2) { + const sortedByRelease = datedTracks + .slice() + .sort( + (a, b) => + Number(a.album?.release_date ?? 0) - + Number(b.album?.release_date ?? 0), + ); + const earliest = sortedByRelease[0]; + const latest = sortedByRelease.at(-1); + const trackNames = sortedByRelease + .map((track) => track.name) + .filter((name): name is string => isUsableText(name)); + + if (earliest?.name) { + const options = buildOptionsWithCorrect(earliest.name, trackNames, 4); + if (options) { + questions.push({ + key: `audio:release-first:${earliest.name}`, + subjectKey: `track:${earliest.name}`, + question: { + type: "choice", + text: "Which of these tracks came out first?", + options, + correct: 0, + points: 10, + song: topSong ?? undefined, + }, + }); + } + } + + if (latest?.name && latest.name !== earliest?.name) { + const options = buildOptionsWithCorrect(latest.name, trackNames, 4); + if (options) { + questions.push({ + key: `audio:release-last:${latest.name}`, + subjectKey: `track:${latest.name}`, + question: { + type: "choice", + text: "Which of these tracks came out most recently?", + options, + correct: 0, + points: 10, + song: topSong ?? undefined, + }, + }); + } + } + } + + const tracksByArtist = new Map(); + for (const track of detailedTracks) { + if (!track.duration) 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); + } + } + + for (const [artistName, artistTracks] of tracksByArtist) { + if (artistTracks.length < 2) continue; + const longest = artistTracks + .slice() + .sort((a, b) => (b.duration ?? 0) - (a.duration ?? 0))[0]; + if (!longest?.name) continue; + const trackNames = artistTracks + .map((track) => track.name) + .filter((name): name is string => isUsableText(name)); + const options = buildOptionsWithCorrect(longest.name, trackNames, 4); + if (options) { + questions.push({ + key: `audio:artist-longest-track:${artistName}:${longest.name}`, + subjectKey: `artist:${artistName}`, + question: { + type: "choice", + text: `What's the longest track by ${artistName}?`, + options, + correct: 0, + points: 10, + song: topSong ?? undefined, + }, + }); + } + } + for (const topTrack of topTracks) { const trackSong = await resolveQuestionSong(dbClient, analytics, { trackName: topTrack.name, diff --git a/api/src/party/numeric-question-generator.ts b/api/src/party/numeric-question-generator.ts index 11c63f9..886a1f0 100644 --- a/api/src/party/numeric-question-generator.ts +++ b/api/src/party/numeric-question-generator.ts @@ -21,6 +21,14 @@ type NumericQuestion = Omit< "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; @@ -29,6 +37,43 @@ type BuildNumericQuestionInput = { 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, @@ -61,6 +106,76 @@ async function getAlbumReleaseYear({ }; } +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, @@ -149,6 +264,26 @@ export async function buildNumericQuestion( }); } + 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({ diff --git a/web/src/components/party/party-view.tsx b/web/src/components/party/party-view.tsx index 416ccc5..243dd70 100644 --- a/web/src/components/party/party-view.tsx +++ b/web/src/components/party/party-view.tsx @@ -1,4 +1,12 @@ import { useParty } from "#/hooks/use-party"; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyTitle, +} from "../ui/empty"; +import { Spinner } from "../ui/spinner"; import { Question } from "./question"; import { QuestionReview } from "./question-review"; import { Results } from "./results"; @@ -6,7 +14,26 @@ import { SpotifyPlayback } from "./spotify-playback"; export function PartyView() { const { party } = useParty(); - if (!party?.data) return null; + if (!party) return null; + if (!party.data) { + if (party.status !== "started") return null; + return ( + + + Preparing party + + Analyzing everyone's music taste and building the first question. + + + +
+ + This can take a moment. +
+
+
+ ); + } switch (party.data.status) { case "running": diff --git a/web/src/routes/index.tsx b/web/src/routes/index.tsx index 35081f9..81a25ed 100644 --- a/web/src/routes/index.tsx +++ b/web/src/routes/index.tsx @@ -20,8 +20,10 @@ function App() { {!user?.lastSyncAt && } {user && party?.data?.status !== "running" && } - {party && !party.data && members.length > 1 && } - {party?.data && } + {party?.status === "created" && !party.data && members.length > 1 && ( + + )} + {party && (party.data || party.status === "started") && } ); }