attempt to resolve song
This commit is contained in:
parent
91726d85b8
commit
c14b7a7308
6 changed files with 148 additions and 25 deletions
|
|
@ -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 =
|
||||||
|
|
|
||||||
|
|
@ -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];
|
||||||
|
|
|
||||||
|
|
@ -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,12 +118,13 @@ async function countFavouriteArtistListeners({
|
||||||
correct,
|
correct,
|
||||||
range: { min: 0, max: members.length },
|
range: { min: 0, max: members.length },
|
||||||
points: 10,
|
points: 10,
|
||||||
|
song: song ?? undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function buildNumericQuestion(
|
export async function buildNumericQuestion(
|
||||||
input: BuildNumericQuestionInput,
|
input: BuildNumericQuestionInput,
|
||||||
): Promise<Question|null> {
|
): Promise<Question | null> {
|
||||||
const questions: NumericQuestion[] = [];
|
const questions: NumericQuestion[] = [];
|
||||||
|
|
||||||
questions.push(await getAlbumReleaseYear(input));
|
questions.push(await getAlbumReleaseYear(input));
|
||||||
|
|
|
||||||
|
|
@ -27,13 +27,18 @@ export async function generatePartyQuestion({
|
||||||
const type: PartyQuestionType =
|
const type: PartyQuestionType =
|
||||||
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({
|
||||||
if (q) return q;
|
db: dbClient,
|
||||||
}
|
analytics,
|
||||||
return buildSocialQuestion(quizState, analytics, members, index);
|
index,
|
||||||
|
members,
|
||||||
|
});
|
||||||
|
if (q) return q;
|
||||||
|
}
|
||||||
|
return buildSocialQuestion(dbClient, quizState, analytics, members, index);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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[],
|
||||||
|
|
|
||||||
|
|
@ -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];
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue