Compare commits
No commits in common. "3b7d669a5db34721dca9697ca2220b7a11eba04d" and "ff733b97745d2dda0ab8d36e1316bfff77179c2a" have entirely different histories.
3b7d669a5d
...
ff733b9774
9 changed files with 24 additions and 563 deletions
|
|
@ -64,61 +64,6 @@ 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,
|
||||||
|
|
@ -289,66 +234,6 @@ 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,12 +1,6 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import type { PartyAnalytics } from "../question-utils";
|
import { getQuestionRange, getReleaseYearRange } 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", () => {
|
||||||
|
|
@ -31,34 +25,3 @@ 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,47 +14,6 @@ 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,
|
||||||
|
|
@ -155,120 +114,6 @@ 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,14 +21,6 @@ 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;
|
||||||
|
|
@ -37,43 +29,6 @@ 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,
|
||||||
|
|
@ -106,76 +61,6 @@ 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,
|
||||||
|
|
@ -264,26 +149,6 @@ 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,7 +39,6 @@ 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;
|
||||||
|
|
@ -181,13 +180,17 @@ function normalizeQuestionKey(value: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTopClusterArtists(analytics: PartyAnalytics): string[] {
|
export function getTopClusterArtists(analytics: PartyAnalytics): string[] {
|
||||||
return getAllClusterArtists(analytics).map((artist) => artist.name);
|
return (analytics?.storyClusters?.[0]?.artists ?? []).map(
|
||||||
|
(artist) => artist.name,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTopClusterTracks(
|
export function getTopClusterTracks(
|
||||||
analytics: PartyAnalytics,
|
analytics: PartyAnalytics,
|
||||||
): AnalyticsTrack[] {
|
): AnalyticsTrack[] {
|
||||||
return getAllClusterTracks(analytics);
|
return (analytics?.storyClusters?.[0]?.tracks ?? []).filter((track) =>
|
||||||
|
isUsableText(track.name),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pickRelevantTrack(
|
export function pickRelevantTrack(
|
||||||
|
|
@ -335,9 +338,9 @@ async function collectSongCandidates({
|
||||||
);
|
);
|
||||||
push(peopleSong);
|
push(peopleSong);
|
||||||
|
|
||||||
const topClusterTracks = getAllClusterTracks(analytics).sort(
|
const topClusterTracks = [...(analytics?.storyClusters?.[0]?.tracks ?? [])]
|
||||||
(a, b) => getTrackScore(b) - getTrackScore(a),
|
.filter((track) => isUsableText(track.name))
|
||||||
);
|
.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, {
|
||||||
|
|
@ -397,9 +400,12 @@ async function resolveSongFromMentionedPeople(
|
||||||
|
|
||||||
if (userIds.length === 0) return null;
|
if (userIds.length === 0) return null;
|
||||||
|
|
||||||
const tracks = getAllClusterTracks(analytics).sort(
|
const tracks = [...(analytics?.storyClusters?.[0]?.tracks ?? [])]
|
||||||
(a, b) => getMemberTrackScore(b, userIds) - getMemberTrackScore(a, userIds),
|
.filter((track) => isUsableText(track.name))
|
||||||
);
|
.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, {
|
||||||
|
|
@ -478,43 +484,6 @@ 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,40 +239,6 @@ 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,9 +469,8 @@ 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 trackInfo = Array.from(memberData.values())
|
const firstData = memberData.values().next().value;
|
||||||
.find((data) => data.tracks.has(trackId))
|
const trackInfo = firstData?.tracks.get(trackId) ?? {
|
||||||
?.tracks.get(trackId) ?? {
|
|
||||||
name: "",
|
name: "",
|
||||||
artists: [],
|
artists: [],
|
||||||
albumName: undefined,
|
albumName: undefined,
|
||||||
|
|
@ -526,9 +525,8 @@ export class PartyAnalysisWorkflow extends ConfiguredInstance {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const artistInfo = Array.from(memberData.values())
|
const firstData = memberData.values().next().value;
|
||||||
.find((data) => data.artists.has(artistId))
|
const artistInfo = firstData?.artists.get(artistId) ?? { name: "" };
|
||||||
?.artists.get(artistId) ?? { name: "" };
|
|
||||||
|
|
||||||
result.set(artistId, {
|
result.set(artistId, {
|
||||||
id: artistId,
|
id: artistId,
|
||||||
|
|
@ -871,8 +869,7 @@ export class PartyAnalysisWorkflow extends ConfiguredInstance {
|
||||||
genres.sort(
|
genres.sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
b.memberCount - a.memberCount ||
|
b.memberCount - a.memberCount ||
|
||||||
b.memberScores.reduce((total, member) => total + member.score, 0) -
|
b.memberScores.length - a.memberScores.length,
|
||||||
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,12 +1,4 @@
|
||||||
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";
|
||||||
|
|
@ -14,26 +6,7 @@ import { SpotifyPlayback } from "./spotify-playback";
|
||||||
|
|
||||||
export function PartyView() {
|
export function PartyView() {
|
||||||
const { party } = useParty();
|
const { party } = useParty();
|
||||||
if (!party) return null;
|
if (!party?.data) 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,10 +20,8 @@ function App() {
|
||||||
<DeviceChoice />
|
<DeviceChoice />
|
||||||
{!user?.lastSyncAt && <SyncButton />}
|
{!user?.lastSyncAt && <SyncButton />}
|
||||||
{user && party?.data?.status !== "running" && <PartyQr />}
|
{user && party?.data?.status !== "running" && <PartyQr />}
|
||||||
{party?.status === "created" && !party.data && members.length > 1 && (
|
{party && !party.data && members.length > 1 && <StartParty />}
|
||||||
<StartParty />
|
{party?.data && <PartyView />}
|
||||||
)}
|
|
||||||
{party && (party.data || party.status === "started") && <PartyView />}
|
|
||||||
</MainContent>
|
</MainContent>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue