attempt to resolve song

This commit is contained in:
Daniel Bulant 2026-05-13 12:00:43 +02:00
parent 91726d85b8
commit c14b7a7308
No known key found for this signature in database
6 changed files with 148 additions and 25 deletions

View file

@ -1,11 +1,12 @@
import type { InferSelectModel } from "drizzle-orm"; 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<InferSelectModel<typeof party>, "data"> & { export type Party = Omit<InferSelectModel<typeof party>, "data"> & {
data: QuizState; data: QuizState;
}; };
export type PartyMember = InferSelectModel<typeof partyMember>; export type PartyMember = InferSelectModel<typeof partyMember>;
export type User = InferSelectModel<typeof user>; export type User = InferSelectModel<typeof user>;
export type Song = InferSelectModel<typeof track>;
export type PartyMemberWithUser = PartyMember & { user: User | null }; export type PartyMemberWithUser = PartyMember & { user: User | null };
@ -33,6 +34,7 @@ type BaseQuestion = {
startTimestamp: number; startTimestamp: number;
endTimestamp: number; endTimestamp: number;
points: number; points: number;
song?: Song;
}; };
export type Question = export type Question =

View file

@ -1,3 +1,4 @@
import type { db as Db } from "../db";
import type { Question } from "../party-types"; import type { Question } from "../party-types";
import { import {
buildOptionsWithCorrect, buildOptionsWithCorrect,
@ -8,16 +9,19 @@ import {
getTopClusterTracks, getTopClusterTracks,
type PartyAnalytics, type PartyAnalytics,
pickRandom, pickRandom,
resolveQuestionSong,
} from "./question-utils"; } from "./question-utils";
export function buildAudioMetadataQuestion( export async function buildAudioMetadataQuestion(
dbClient: typeof Db,
analytics: PartyAnalytics, analytics: PartyAnalytics,
index: number, index: number,
): Question | null { ): Promise<Question | null> {
type ChoiceQuestion = Extract<Question, { type: "choice" }>; type ChoiceQuestion = Extract<Question, { type: "choice" }>;
const questions: Array< const questions: Array<
Omit<ChoiceQuestion, "startTimestamp" | "endTimestamp"> Omit<ChoiceQuestion, "startTimestamp" | "endTimestamp">
> = []; > = [];
const topSong = await resolveQuestionSong(dbClient, analytics);
const genreOptions = buildOrderedOptions( const genreOptions = buildOrderedOptions(
getMostSharedGenreNames(analytics), getMostSharedGenreNames(analytics),
@ -30,6 +34,7 @@ export function buildAudioMetadataQuestion(
options: genreOptions, options: genreOptions,
correct: 0, correct: 0,
points: 10, points: 10,
song: topSong ?? undefined,
}); });
} }
@ -44,6 +49,7 @@ export function buildAudioMetadataQuestion(
options: artistOptions, options: artistOptions,
correct: 0, correct: 0,
points: 10, points: 10,
song: topSong ?? undefined,
}); });
} }
} }
@ -62,12 +68,18 @@ export function buildAudioMetadataQuestion(
options: trackOptions, options: trackOptions,
correct: 0, correct: 0,
points: 10, points: 10,
song: topSong ?? undefined,
}); });
} }
} }
const randomTopTrack = pickRandom(topTracks); const randomTopTrack = pickRandom(topTracks);
if (randomTopTrack) { if (randomTopTrack) {
const randomTrackSong = await resolveQuestionSong(dbClient, analytics, {
trackName: randomTopTrack.name,
artistNames: randomTopTrack.artists?.map((artist) => artist.name),
albumName: randomTopTrack.albumName,
});
const trackArtists = const trackArtists =
randomTopTrack.artists?.map((artist) => artist.name) ?? []; randomTopTrack.artists?.map((artist) => artist.name) ?? [];
const allArtists = topArtists.length > 0 ? topArtists : trackArtists; const allArtists = topArtists.length > 0 ? topArtists : trackArtists;
@ -85,6 +97,7 @@ export function buildAudioMetadataQuestion(
options: artistOptions, options: artistOptions,
correct: 0, correct: 0,
points: 10, points: 10,
song: randomTrackSong ?? topSong ?? undefined,
}); });
} }
@ -101,6 +114,7 @@ export function buildAudioMetadataQuestion(
options: trackNameOptions, options: trackNameOptions,
correct: 0, correct: 0,
points: 10, points: 10,
song: randomTrackSong ?? topSong ?? undefined,
}); });
} }
} }
@ -121,13 +135,14 @@ export function buildAudioMetadataQuestion(
options: albumOptions, options: albumOptions,
correct: 0, correct: 0,
points: 10, points: 10,
song: randomTrackSong ?? topSong ?? undefined,
}); });
} }
} }
} }
if (questions.length === 0) { if (questions.length === 0) {
return null return null;
} }
const question = questions[index % questions.length]; const question = questions[index % questions.length];

View file

@ -10,6 +10,7 @@ import {
getQuestionRange, getQuestionRange,
type PartyAnalytics, type PartyAnalytics,
type PartyQuestionMember, type PartyQuestionMember,
resolveQuestionSong,
} from "./question-utils"; } from "./question-utils";
type NumericQuestion = Omit< type NumericQuestion = Omit<
@ -36,6 +37,9 @@ async function getAlbumReleaseYear({
with: { album: true }, with: { album: true },
}) })
: null; : null;
const song = await resolveQuestionSong(db, analytics, {
trackName: track?.name ?? trackName ?? undefined,
});
const correct = const correct =
track?.album?.release_date?.getFullYear() ?? track?.album?.release_date?.getFullYear() ??
new Date().getFullYear() - 1 - index; new Date().getFullYear() - 1 - index;
@ -46,6 +50,7 @@ async function getAlbumReleaseYear({
correct, correct,
range: getQuestionRange(correct, 10, 3), range: getQuestionRange(correct, 10, 3),
points: 10, points: 10,
song: song ?? undefined,
}; };
} }
@ -60,6 +65,7 @@ async function countTopTrackListeners({
where: { name: trackName }, where: { name: trackName },
}); });
if (!dbTrack) return null; if (!dbTrack) return null;
const song = await resolveQuestionSong(db, analytics, { trackName });
const memberIds = members.map((m) => m.userId); const memberIds = members.map((m) => m.userId);
const entries = await db const entries = await db
.select({ userId: topTrackTable.userId }) .select({ userId: topTrackTable.userId })
@ -77,6 +83,7 @@ async function countTopTrackListeners({
correct, correct,
range: { min: 0, max: members.length }, range: { min: 0, max: members.length },
points: 10, points: 10,
song: song ?? undefined,
}; };
} }
@ -91,6 +98,9 @@ async function countFavouriteArtistListeners({
where: { name: artistName }, where: { name: artistName },
}); });
if (!dbArtist) return null; if (!dbArtist) return null;
const song = await resolveQuestionSong(db, analytics, {
artistNames: [artistName],
});
const memberIds = members.map((m) => m.userId); const memberIds = members.map((m) => m.userId);
const entries = await db const entries = await db
.select({ userId: topArtistTable.userId }) .select({ userId: topArtistTable.userId })
@ -108,6 +118,7 @@ async function countFavouriteArtistListeners({
correct, correct,
range: { min: 0, max: members.length }, range: { min: 0, max: members.length },
points: 10, points: 10,
song: song ?? undefined,
}; };
} }

View file

@ -28,12 +28,17 @@ export async function generatePartyQuestion({
index === 2 ? "numeric" : index % 2 === 0 ? "audio-metadata" : "social"; index === 2 ? "numeric" : index % 2 === 0 ? "audio-metadata" : "social";
if (type === "audio-metadata") { if (type === "audio-metadata") {
let q = buildAudioMetadataQuestion(analytics, index); const q = await buildAudioMetadataQuestion(dbClient, analytics, index);
if (q) return q; if (q) return q;
} }
if (type === "numeric") { if (type === "numeric") {
let q = await buildNumericQuestion({ db: dbClient, analytics, index, members }); const q = await buildNumericQuestion({
db: dbClient,
analytics,
index,
members,
});
if (q) return q; if (q) return q;
} }
return buildSocialQuestion(quizState, analytics, members, index); return buildSocialQuestion(dbClient, quizState, analytics, members, index);
} }

View file

@ -1,4 +1,6 @@
import type { InferSelectModel } from "drizzle-orm";
import type { db as Db } from "../db"; import type { db as Db } from "../db";
import type { track as trackTable } from "../db/schema";
export type PartyQuestionMember = { export type PartyQuestionMember = {
userId: string; userId: string;
@ -23,6 +25,14 @@ export type PartyAnalytics = {
pairwise?: { userIdA: string; userIdB: string }[]; pairwise?: { userIdA: string; userIdB: string }[];
} | null; } | null;
export type AnalyticsTrack = {
name: string;
artists?: { name: string }[];
albumName?: string;
memberScores?: { userId: string; score: number }[];
};
export type QuestionSong = InferSelectModel<typeof trackTable>;
export const QUESTION_DURATION_MS = 60_000; export const QUESTION_DURATION_MS = 60_000;
export const MIN_PARTY_SIZE = 2; export const MIN_PARTY_SIZE = 2;
export const MAX_PARTY_SIZE = 4; export const MAX_PARTY_SIZE = 4;
@ -100,15 +110,80 @@ export function getTopClusterArtists(analytics: PartyAnalytics): string[] {
); );
} }
export function getTopClusterTracks(analytics: PartyAnalytics): Array<{ export function getTopClusterTracks(
name: string; analytics: PartyAnalytics,
artists?: { name: string }[]; ): AnalyticsTrack[] {
albumName?: string;
memberScores?: { userId: string; score: number }[];
}> {
return analytics?.storyClusters?.[0]?.tracks ?? []; 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<QuestionSong | null> {
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( export function getTopTrackListener(
track: { memberScores?: { userId: string; score: number }[] }, track: { memberScores?: { userId: string; score: number }[] },
members: PartyQuestionMember[], members: PartyQuestionMember[],

View file

@ -1,3 +1,4 @@
import type { db as Db } from "../db";
import type { Question, QuizState } from "../party-types"; import type { Question, QuizState } from "../party-types";
import { import {
buildMemberOptions, buildMemberOptions,
@ -12,18 +13,21 @@ import {
type PartyAnalytics, type PartyAnalytics,
type PartyQuestionMember, type PartyQuestionMember,
pickRandom, pickRandom,
resolveQuestionSong,
} from "./question-utils"; } from "./question-utils";
export function buildSocialQuestion( export async function buildSocialQuestion(
dbClient: typeof Db,
quizState: QuizState, quizState: QuizState,
analytics: PartyAnalytics, analytics: PartyAnalytics,
members: PartyQuestionMember[], members: PartyQuestionMember[],
index: number, index: number,
): Question | null { ): Promise<Question | null> {
type ChoiceQuestion = Extract<Question, { type: "choice" }>; type ChoiceQuestion = Extract<Question, { type: "choice" }>;
const questions: Array< const questions: Array<
Omit<ChoiceQuestion, "startTimestamp" | "endTimestamp"> Omit<ChoiceQuestion, "startTimestamp" | "endTimestamp">
> = []; > = [];
const topSong = await resolveQuestionSong(dbClient, analytics);
const hasMultipleMembers = members.length >= 2; const hasMultipleMembers = members.length >= 2;
if (hasMultipleMembers && hasClearLeader(quizState)) { if (hasMultipleMembers && hasClearLeader(quizState)) {
@ -34,6 +38,7 @@ export function buildSocialQuestion(
options: buildMemberOptions(leader, members), options: buildMemberOptions(leader, members),
correct: 0, correct: 0,
points: 10, points: 10,
song: topSong ?? undefined,
}); });
} }
@ -45,6 +50,7 @@ export function buildSocialQuestion(
options: buildMemberOptions(diverse, members), options: buildMemberOptions(diverse, members),
correct: 0, correct: 0,
points: 10, points: 10,
song: topSong ?? undefined,
}); });
const aligned = getMostAlignedMember(analytics, members); const aligned = getMostAlignedMember(analytics, members);
@ -54,6 +60,7 @@ export function buildSocialQuestion(
options: buildMemberOptions(aligned, members), options: buildMemberOptions(aligned, members),
correct: 0, correct: 0,
points: 10, points: 10,
song: topSong ?? undefined,
}); });
} }
@ -62,12 +69,18 @@ export function buildSocialQuestion(
if (randomTrack && hasMultipleMembers) { if (randomTrack && hasMultipleMembers) {
const topListener = getTopTrackListener(randomTrack, members); const topListener = getTopTrackListener(randomTrack, members);
if (topListener) { if (topListener) {
const randomTrackSong = await resolveQuestionSong(dbClient, analytics, {
trackName: randomTrack.name,
artistNames: randomTrack.artists?.map((artist) => artist.name),
albumName: randomTrack.albumName,
});
questions.push({ questions.push({
type: "choice", type: "choice",
text: `Who listens the most to "${randomTrack.name}"?`, text: `Who listens the most to "${randomTrack.name}"?`,
options: buildMemberOptions(topListener, members), options: buildMemberOptions(topListener, members),
correct: 0, correct: 0,
points: 10, points: 10,
song: randomTrackSong ?? topSong ?? undefined,
}); });
questions.push({ questions.push({
type: "choice", type: "choice",
@ -75,6 +88,7 @@ export function buildSocialQuestion(
options: buildMemberOptions(topListener, members), options: buildMemberOptions(topListener, members),
correct: 0, correct: 0,
points: 10, points: 10,
song: randomTrackSong ?? topSong ?? undefined,
}); });
} }
} }
@ -93,13 +107,14 @@ export function buildSocialQuestion(
options: pairOptions, options: pairOptions,
correct: 0, correct: 0,
points: 10, points: 10,
song: topSong ?? undefined,
}); });
} }
} }
} }
if (questions.length === 0) { if (questions.length === 0) {
return null return null;
} }
const question = questions[index % questions.length]; const question = questions[index % questions.length];