Compare commits
2 commits
ff733b9774
...
3b7d669a5d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b7d669a5d | ||
|
|
2154c0b6d1 |
9 changed files with 563 additions and 24 deletions
|
|
@ -64,6 +64,61 @@ function createFakeDb(trackReleaseDate: Date | null) {
|
||||||
} as unknown as Db;
|
} 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 {
|
function makeSong(id: string, platformId: string, name: string): Song {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
|
|
@ -234,6 +289,66 @@ describe("question generation", () => {
|
||||||
expect(question).toBeNull();
|
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 () => {
|
it("skips social fallback names for duplicate members", async () => {
|
||||||
const db = createFakeDb(null);
|
const db = createFakeDb(null);
|
||||||
const members: PartyQuestionMember[] = [
|
const members: PartyQuestionMember[] = [
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { getQuestionRange, getReleaseYearRange } from "../question-utils";
|
import type { PartyAnalytics } from "../question-utils";
|
||||||
|
import {
|
||||||
|
getQuestionRange,
|
||||||
|
getReleaseYearRange,
|
||||||
|
getTopClusterArtists,
|
||||||
|
getTopClusterTracks,
|
||||||
|
} from "../question-utils";
|
||||||
|
|
||||||
describe("question range helpers", () => {
|
describe("question range helpers", () => {
|
||||||
it("normalizes inverted generic ranges", () => {
|
it("normalizes inverted generic ranges", () => {
|
||||||
|
|
@ -25,3 +31,34 @@ describe("question range helpers", () => {
|
||||||
expect(range.max).toBeLessThanOrEqual(2026);
|
expect(range.max).toBeLessThanOrEqual(2026);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("analytics cluster helpers", () => {
|
||||||
|
it("returns usable tracks and artists across all story clusters", () => {
|
||||||
|
const analytics: PartyAnalytics = {
|
||||||
|
storyClusters: [
|
||||||
|
{
|
||||||
|
tracks: [
|
||||||
|
{ name: "Shared Track", artists: [{ name: "Shared Artist" }] },
|
||||||
|
],
|
||||||
|
artists: [{ name: "Shared Artist" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tracks: [
|
||||||
|
{ name: "Solo Track", artists: [{ name: "Solo Artist" }] },
|
||||||
|
{ name: "" },
|
||||||
|
],
|
||||||
|
artists: [{ name: "Solo Artist" }, { name: "" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(getTopClusterTracks(analytics).map((track) => track.name)).toEqual([
|
||||||
|
"Shared Track",
|
||||||
|
"Solo Track",
|
||||||
|
]);
|
||||||
|
expect(getTopClusterArtists(analytics)).toEqual([
|
||||||
|
"Shared Artist",
|
||||||
|
"Solo Artist",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,47 @@ import {
|
||||||
resolveQuestionSong,
|
resolveQuestionSong,
|
||||||
} from "./question-utils";
|
} 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<TrackDetails[]> {
|
||||||
|
const tracks: TrackDetails[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
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(
|
export async function buildAudioMetadataQuestion(
|
||||||
dbClient: typeof Db,
|
dbClient: typeof Db,
|
||||||
analytics: PartyAnalytics,
|
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<string, TrackDetails[]>();
|
||||||
|
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) {
|
for (const topTrack of topTracks) {
|
||||||
const trackSong = await resolveQuestionSong(dbClient, analytics, {
|
const trackSong = await resolveQuestionSong(dbClient, analytics, {
|
||||||
trackName: topTrack.name,
|
trackName: topTrack.name,
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,14 @@ type NumericQuestion = Omit<
|
||||||
"startTimestamp" | "endTimestamp"
|
"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 = {
|
type BuildNumericQuestionInput = {
|
||||||
db: typeof db;
|
db: typeof db;
|
||||||
analytics: PartyAnalytics;
|
analytics: PartyAnalytics;
|
||||||
|
|
@ -29,6 +37,43 @@ type BuildNumericQuestionInput = {
|
||||||
history: QuizRound[];
|
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({
|
async function getAlbumReleaseYear({
|
||||||
db,
|
db,
|
||||||
analytics,
|
analytics,
|
||||||
|
|
@ -61,6 +106,76 @@ async function getAlbumReleaseYear({
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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({
|
async function countTopTrackListeners({
|
||||||
db,
|
db,
|
||||||
analytics,
|
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);
|
const topTrackQ = await countTopTrackListeners(input);
|
||||||
if (topTrackQ) {
|
if (topTrackQ) {
|
||||||
questions.push({
|
questions.push({
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ export type AnalyticsTrack = {
|
||||||
albumName?: string;
|
albumName?: string;
|
||||||
memberScores?: { userId: string; score: number }[];
|
memberScores?: { userId: string; score: number }[];
|
||||||
};
|
};
|
||||||
|
type AnalyticsArtist = { name: string };
|
||||||
type QuestionLike = {
|
type QuestionLike = {
|
||||||
text: string;
|
text: string;
|
||||||
questionKey?: string;
|
questionKey?: string;
|
||||||
|
|
@ -180,17 +181,13 @@ function normalizeQuestionKey(value: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTopClusterArtists(analytics: PartyAnalytics): string[] {
|
export function getTopClusterArtists(analytics: PartyAnalytics): string[] {
|
||||||
return (analytics?.storyClusters?.[0]?.artists ?? []).map(
|
return getAllClusterArtists(analytics).map((artist) => artist.name);
|
||||||
(artist) => artist.name,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTopClusterTracks(
|
export function getTopClusterTracks(
|
||||||
analytics: PartyAnalytics,
|
analytics: PartyAnalytics,
|
||||||
): AnalyticsTrack[] {
|
): AnalyticsTrack[] {
|
||||||
return (analytics?.storyClusters?.[0]?.tracks ?? []).filter((track) =>
|
return getAllClusterTracks(analytics);
|
||||||
isUsableText(track.name),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pickRelevantTrack(
|
export function pickRelevantTrack(
|
||||||
|
|
@ -338,9 +335,9 @@ async function collectSongCandidates({
|
||||||
);
|
);
|
||||||
push(peopleSong);
|
push(peopleSong);
|
||||||
|
|
||||||
const topClusterTracks = [...(analytics?.storyClusters?.[0]?.tracks ?? [])]
|
const topClusterTracks = getAllClusterTracks(analytics).sort(
|
||||||
.filter((track) => isUsableText(track.name))
|
(a, b) => getTrackScore(b) - getTrackScore(a),
|
||||||
.sort((a, b) => getTrackScore(b) - getTrackScore(a));
|
);
|
||||||
|
|
||||||
for (const track of topClusterTracks) {
|
for (const track of topClusterTracks) {
|
||||||
const song = await resolveQuestionSong(db, analytics, {
|
const song = await resolveQuestionSong(db, analytics, {
|
||||||
|
|
@ -400,12 +397,9 @@ async function resolveSongFromMentionedPeople(
|
||||||
|
|
||||||
if (userIds.length === 0) return null;
|
if (userIds.length === 0) return null;
|
||||||
|
|
||||||
const tracks = [...(analytics?.storyClusters?.[0]?.tracks ?? [])]
|
const tracks = getAllClusterTracks(analytics).sort(
|
||||||
.filter((track) => isUsableText(track.name))
|
(a, b) => getMemberTrackScore(b, userIds) - getMemberTrackScore(a, userIds),
|
||||||
.sort(
|
);
|
||||||
(a, b) =>
|
|
||||||
getMemberTrackScore(b, userIds) - getMemberTrackScore(a, userIds),
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const track of tracks) {
|
for (const track of tracks) {
|
||||||
const song = await resolveQuestionSong(db, analytics, {
|
const song = await resolveQuestionSong(db, analytics, {
|
||||||
|
|
@ -484,6 +478,43 @@ function getMemberTrackScore(
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getAllClusterTracks(analytics: PartyAnalytics): AnalyticsTrack[] {
|
||||||
|
const tracks: AnalyticsTrack[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (const cluster of analytics?.storyClusters ?? []) {
|
||||||
|
for (const track of cluster.tracks ?? []) {
|
||||||
|
if (!isUsableText(track.name)) continue;
|
||||||
|
const key = [
|
||||||
|
track.name,
|
||||||
|
track.albumName ?? "",
|
||||||
|
track.artists?.map((artist) => artist.name).join("|") ?? "",
|
||||||
|
].join("::");
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
tracks.push(track);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tracks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAllClusterArtists(analytics: PartyAnalytics): AnalyticsArtist[] {
|
||||||
|
const artists: AnalyticsArtist[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (const cluster of analytics?.storyClusters ?? []) {
|
||||||
|
for (const artist of cluster.artists ?? []) {
|
||||||
|
if (!isUsableText(artist.name)) continue;
|
||||||
|
if (seen.has(artist.name)) continue;
|
||||||
|
seen.add(artist.name);
|
||||||
|
artists.push(artist);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return artists;
|
||||||
|
}
|
||||||
|
|
||||||
export function isUsableText(
|
export function isUsableText(
|
||||||
value: string | null | undefined,
|
value: string | null | undefined,
|
||||||
): value is string {
|
): value is string {
|
||||||
|
|
|
||||||
|
|
@ -239,6 +239,40 @@ describe("PartyAnalysisWorkflow", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("analyzeParty - story clusters", () => {
|
describe("analyzeParty - story clusters", () => {
|
||||||
|
it("keeps names for entities that only appear for later members", async () => {
|
||||||
|
const { partyId, userIdB } = await seedPartyWithTwoSimilarUsers();
|
||||||
|
const uniqueAlbum = await createAlbum("User B Unique Album");
|
||||||
|
const uniqueArtist = await createArtist("User B Unique Artist");
|
||||||
|
const uniqueTrack = await createTrack(
|
||||||
|
"User B Unique Track",
|
||||||
|
uniqueAlbum.id,
|
||||||
|
[uniqueArtist.id],
|
||||||
|
);
|
||||||
|
await addTopTrack(userIdB, uniqueTrack.id, 2);
|
||||||
|
await addTopArtist(userIdB, uniqueArtist.id, 2);
|
||||||
|
|
||||||
|
const result = await partyAnalysisWorkflow.analyzeParty(partyId);
|
||||||
|
const allTracks = result.storyClusters.flatMap(
|
||||||
|
(cluster) => cluster.tracks,
|
||||||
|
);
|
||||||
|
const allArtists = result.storyClusters.flatMap(
|
||||||
|
(cluster) => cluster.artists,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(allTracks).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: uniqueTrack.id,
|
||||||
|
name: "User B Unique Track",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(allArtists).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: uniqueArtist.id,
|
||||||
|
name: "User B Unique Artist",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("sorts clusters with all-member cluster first", async () => {
|
it("sorts clusters with all-member cluster first", async () => {
|
||||||
const { partyId, sharedTrackId } = await seedPartyWithTwoSimilarUsers();
|
const { partyId, sharedTrackId } = await seedPartyWithTwoSimilarUsers();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -469,8 +469,9 @@ export class PartyAnalysisWorkflow extends ConfiguredInstance {
|
||||||
const result = new Map<string, TrackEntityScore>();
|
const result = new Map<string, TrackEntityScore>();
|
||||||
|
|
||||||
for (const [trackId, memberScores] of entityMap) {
|
for (const [trackId, memberScores] of entityMap) {
|
||||||
const firstData = memberData.values().next().value;
|
const trackInfo = Array.from(memberData.values())
|
||||||
const trackInfo = firstData?.tracks.get(trackId) ?? {
|
.find((data) => data.tracks.has(trackId))
|
||||||
|
?.tracks.get(trackId) ?? {
|
||||||
name: "",
|
name: "",
|
||||||
artists: [],
|
artists: [],
|
||||||
albumName: undefined,
|
albumName: undefined,
|
||||||
|
|
@ -525,8 +526,9 @@ export class PartyAnalysisWorkflow extends ConfiguredInstance {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const firstData = memberData.values().next().value;
|
const artistInfo = Array.from(memberData.values())
|
||||||
const artistInfo = firstData?.artists.get(artistId) ?? { name: "" };
|
.find((data) => data.artists.has(artistId))
|
||||||
|
?.artists.get(artistId) ?? { name: "" };
|
||||||
|
|
||||||
result.set(artistId, {
|
result.set(artistId, {
|
||||||
id: artistId,
|
id: artistId,
|
||||||
|
|
@ -869,7 +871,8 @@ export class PartyAnalysisWorkflow extends ConfiguredInstance {
|
||||||
genres.sort(
|
genres.sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
b.memberCount - a.memberCount ||
|
b.memberCount - a.memberCount ||
|
||||||
b.memberScores.length - a.memberScores.length,
|
b.memberScores.reduce((total, member) => total + member.score, 0) -
|
||||||
|
a.memberScores.reduce((total, member) => total + member.score, 0),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Return top genres that are shared by at least 2 members
|
// Return top genres that are shared by at least 2 members
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,12 @@
|
||||||
import { useParty } from "#/hooks/use-party";
|
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 { Question } from "./question";
|
||||||
import { QuestionReview } from "./question-review";
|
import { QuestionReview } from "./question-review";
|
||||||
import { Results } from "./results";
|
import { Results } from "./results";
|
||||||
|
|
@ -6,7 +14,26 @@ import { SpotifyPlayback } from "./spotify-playback";
|
||||||
|
|
||||||
export function PartyView() {
|
export function PartyView() {
|
||||||
const { party } = useParty();
|
const { party } = useParty();
|
||||||
if (!party?.data) return null;
|
if (!party) return null;
|
||||||
|
if (!party.data) {
|
||||||
|
if (party.status !== "started") return null;
|
||||||
|
return (
|
||||||
|
<Empty>
|
||||||
|
<EmptyHeader>
|
||||||
|
<EmptyTitle>Preparing party</EmptyTitle>
|
||||||
|
<EmptyDescription>
|
||||||
|
Analyzing everyone's music taste and building the first question.
|
||||||
|
</EmptyDescription>
|
||||||
|
</EmptyHeader>
|
||||||
|
<EmptyContent>
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<Spinner />
|
||||||
|
<span>This can take a moment.</span>
|
||||||
|
</div>
|
||||||
|
</EmptyContent>
|
||||||
|
</Empty>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
switch (party.data.status) {
|
switch (party.data.status) {
|
||||||
case "running":
|
case "running":
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,10 @@ function App() {
|
||||||
<DeviceChoice />
|
<DeviceChoice />
|
||||||
{!user?.lastSyncAt && <SyncButton />}
|
{!user?.lastSyncAt && <SyncButton />}
|
||||||
{user && party?.data?.status !== "running" && <PartyQr />}
|
{user && party?.data?.status !== "running" && <PartyQr />}
|
||||||
{party && !party.data && members.length > 1 && <StartParty />}
|
{party?.status === "created" && !party.data && members.length > 1 && (
|
||||||
{party?.data && <PartyView />}
|
<StartParty />
|
||||||
|
)}
|
||||||
|
{party && (party.data || party.status === "started") && <PartyView />}
|
||||||
</MainContent>
|
</MainContent>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue