basic party analysis

This commit is contained in:
Daniel Bulant 2026-04-25 13:18:17 +02:00
parent 2d16fb8ecc
commit e2fee7850c
No known key found for this signature in database
3 changed files with 983 additions and 1 deletions

View file

@ -1,8 +1,10 @@
import { DBOS } from "@dbos-inc/dbos-sdk";
import { Elysia } from "elysia";
import { betterAuthElysia } from "./auth";
import { partyAnalysisApp } from "./routes/party-analysis";
import { syncApp } from "./routes/sync";
import "./workflows/sync";
import "./workflows/party-analysis";
import "./dbos.ts";
import { partyApp } from "./routes/party";
import { partySocketApp } from "./routes/party-socket";
@ -11,7 +13,12 @@ import { statsApp } from "./routes/stats.ts";
const app = new Elysia()
.use(betterAuthElysia)
.group("/api", (app) =>
app.use(syncApp).use(statsApp).use(partyApp).use(partySocketApp),
app
.use(syncApp)
.use(statsApp)
.use(partyApp)
.use(partyAnalysisApp)
.use(partySocketApp),
)
.listen(4000);

View file

@ -0,0 +1,63 @@
import Elysia from "elysia";
import { betterAuthElysia } from "../auth";
import { db } from "../db";
import { getMemberRecord } from "../party-data";
import { partyAnalysisWorkflow } from "../workflows/party-analysis";
export const partyAnalysisApp = new Elysia()
.use(betterAuthElysia)
.post(
"/party/analyze",
async ({ user, set }) => {
const membership = await getMemberRecord(db, user.id);
if (!membership) {
set.status = 400;
return { error: "You are not in a party." };
}
const currentParty = await db.query.party.findFirst({
where: {
id: membership.partyId,
},
});
if (!currentParty || currentParty.hostId !== user.id) {
set.status = 403;
return { error: "Only the host can trigger analysis." };
}
const result = await partyAnalysisWorkflow.analyzeParty(
membership.partyId,
);
return result;
},
{
auth: true,
},
)
.get(
"/party/analysis",
async ({ user, set }) => {
const membership = await getMemberRecord(db, user.id);
if (!membership) {
set.status = 404;
return { error: "Party not found." };
}
const partyRecord = await db.query.party.findFirst({
where: {
id: membership.partyId,
},
});
if (!partyRecord?.analysisData) {
set.status = 404;
return { error: "No analysis data available." };
}
return partyRecord.analysisData;
},
{
auth: true,
},
);

View file

@ -0,0 +1,912 @@
import { ConfiguredInstance, DBOS } from "@dbos-inc/dbos-sdk";
import { sql } from "drizzle-orm";
import { db } from "../db";
import { party, partyMember } from "../db/schema";
type PartyMemberRow = {
id: string;
partyId: string;
userId: string;
joinedAt: Date;
lastSeen: Date;
};
const MAX_POSITION = 50;
const SAVED_SCORE = 10;
const FOLLOWED_SCORE = 10;
const PLAYBACK_TODAY_SCORE = 5;
const PLAYBACK_WEEK_SCORE = 3;
const PLAYBACK_OLD_SCORE = 1;
type MemberScore = {
userId: string;
score: number;
};
type TrackEntityScore = {
id: string;
name: string;
artists: { id: string; name: string }[];
albumName?: string;
memberScores: MemberScore[];
memberCount: number;
};
type ArtistEntityScore = {
id: string;
name: string;
memberScores: MemberScore[];
memberCount: number;
};
type GenreEntityScore = {
id: string;
name: string;
memberScores: MemberScore[];
memberCount: number;
};
type StoryCluster = {
memberIds: string[];
memberCount: number;
tracks: TrackEntityScore[];
artists: ArtistEntityScore[];
genres: GenreEntityScore[];
};
type PairwiseComparison = {
userIdA: string;
userIdB: string;
sharedTracks: number;
sharedArtists: number;
sharedGenres: number;
similarity: number;
};
type GenreDiversity = {
userId: string;
genreEntropy: number;
totalGenreScore: number;
};
type GroupSummary = {
totalMembers: number;
mostSharedGenres: GenreEntityScore[];
mostDiverseMember: GenreDiversity | null;
mostAlignedPair: PairwiseComparison | null;
};
type MemberProfile = {
userId: string;
totalScore: number;
genreScores: Record<string, number>;
trackCount: number;
artistCount: number;
};
type PartyAnalysisResult = {
storyClusters: StoryCluster[];
pairwise: PairwiseComparison[];
groupSummary: GroupSummary;
memberProfiles: MemberProfile[];
};
export class PartyAnalysisWorkflow extends ConfiguredInstance {
@DBOS.workflow()
async analyzeParty(partyId: string): Promise<PartyAnalysisResult> {
const members = await this.fetchPartyMembers(partyId);
if (members.length < 2) {
return {
storyClusters: [],
pairwise: [],
groupSummary: {
totalMembers: members.length,
mostSharedGenres: [],
mostDiverseMember: null,
mostAlignedPair: null,
},
memberProfiles: [],
};
}
const memberInfos = members.map((m) => ({
userId: m.userId,
userName: "",
}));
const memberData = await this.fetchAllMemberData(memberInfos);
const _memberMap = new Map(memberInfos.map((m) => [m.userId, m]));
const trackMap = this.buildTrackEntityMap(memberData);
const artistMap = this.buildArtistEntityMap(memberData);
const genreMap = this.buildGenreEntityMap(memberData);
const storyClusters = this.computeStoryClusters(
trackMap,
artistMap,
genreMap,
memberInfos.map((m) => m.userId),
);
const pairwise = this.computePairwise(memberData, memberInfos);
const memberProfiles = this.buildMemberProfiles(memberData);
const genreDiversityList = this.computeGenreDiversity(memberProfiles);
const allGenreScores = this.aggregateAllGenreScores(memberData);
const mostSharedGenres = this.getMostSharedGenres(
allGenreScores,
memberData,
members.length,
);
const mostDiverseMember =
genreDiversityList.length > 0
? (genreDiversityList
.sort((a, b) => b.genreEntropy - a.genreEntropy)
.at(0) ?? null)
: null;
const mostAlignedPair =
pairwise.length > 0
? (pairwise.sort((a, b) => b.similarity - a.similarity).at(0) ?? null)
: null;
const groupSummary: GroupSummary = {
totalMembers: members.length,
mostSharedGenres,
mostDiverseMember,
mostAlignedPair,
};
await this.saveAnalysis(partyId, {
storyClusters,
pairwise,
groupSummary,
memberProfiles,
});
return {
storyClusters,
pairwise,
groupSummary,
memberProfiles,
};
}
@DBOS.step()
private async fetchPartyMembers(partyId: string): Promise<PartyMemberRow[]> {
const result = await db
.select()
.from(partyMember)
.where(sql`${partyMember.partyId} = ${partyId}`)
.execute();
return result as PartyMemberRow[];
}
@DBOS.step()
private async fetchAllMemberData(
members: { userId: string }[],
): Promise<Map<string, MemberScores>> {
const result = new Map<string, MemberScores>();
for (const member of members) {
const scores = await this.fetchMemberScores(member.userId);
result.set(member.userId, scores);
}
return result;
}
@DBOS.step()
private async fetchMemberScores(userId: string): Promise<MemberScores> {
const scores: MemberScores = {
tracks: new Map(),
artists: new Map(),
genreScores: new Map(),
genreNames: new Map(),
};
const now = new Date();
// Top tracks (medium_term) with position-based scoring
const topTracks = await db.query.topTrack.findMany({
where: {
userId,
timeline: "medium_term",
},
with: {
track: {
with: {
artists: {
with: {
genres: true,
},
},
album: true,
},
},
},
orderBy: {
position: "asc",
},
limit: MAX_POSITION,
});
for (const t of topTracks) {
const trackScore = MAX_POSITION - t.position + 1;
if (!t.track) continue;
this.addTrackScore(scores, t.track.id, trackScore, t.track);
// Add to artist scores
for (const artist of t.track.artists) {
this.addArtistScore(scores, artist.id, trackScore, artist);
// Add to genre scores
for (const g of artist.genres) {
this.addGenreScore(scores, g.id, trackScore, g.name);
}
}
}
// Saved tracks
const savedTracks = await db.query.savedTrack.findMany({
where: { userId },
with: {
track: {
with: {
artists: {
with: {
genres: true,
},
},
album: true,
},
},
},
});
for (const t of savedTracks) {
if (!t.track) continue;
this.addTrackScore(scores, t.track.id, SAVED_SCORE, t.track);
for (const artist of t.track.artists) {
this.addArtistScore(scores, artist.id, SAVED_SCORE / 2, artist);
for (const g of artist.genres) {
this.addGenreScore(scores, g.id, SAVED_SCORE / 4, g.name);
}
}
}
// Playback history
const playbackHistory = await db.query.playbackHistory.findMany({
where: { userId },
with: {
track: {
with: {
artists: {
with: {
genres: true,
},
},
album: true,
},
},
},
});
for (const h of playbackHistory) {
if (!h.track) continue;
const hoursSince =
(now.getTime() - h.played_at.getTime()) / (1000 * 60 * 60);
let playbackScore: number;
if (hoursSince < 24) {
playbackScore = PLAYBACK_TODAY_SCORE;
} else if (hoursSince < 168) {
playbackScore = PLAYBACK_WEEK_SCORE;
} else {
playbackScore = PLAYBACK_OLD_SCORE;
}
this.addTrackScore(scores, h.track.id, playbackScore, h.track);
for (const artist of h.track.artists) {
this.addArtistScore(scores, artist.id, playbackScore / 2, artist);
for (const g of artist.genres) {
this.addGenreScore(scores, g.id, playbackScore / 4, g.name);
}
}
}
// Top artists (medium_term)
const topArtists = await db.query.topArtist.findMany({
where: {
userId,
timeline: "medium_term",
},
with: {
artist: {
with: {
genres: true,
},
},
},
orderBy: {
position: "asc",
},
limit: MAX_POSITION,
});
for (const a of topArtists) {
const artistScore = MAX_POSITION - a.position + 1;
if (!a.artist) continue;
this.addArtistScore(scores, a.artist.id, artistScore, a.artist);
for (const g of a.artist.genres) {
this.addGenreScore(scores, g.id, artistScore, g.name);
}
}
// Followed artists
const followedArtists = await db.query.followedArtist.findMany({
where: { userId },
with: {
artist: {
with: {
genres: true,
},
},
},
});
for (const fa of followedArtists) {
if (!fa.artist) continue;
this.addArtistScore(scores, fa.artist.id, FOLLOWED_SCORE, fa.artist);
for (const g of fa.artist.genres) {
this.addGenreScore(scores, g.id, FOLLOWED_SCORE, g.name);
}
}
// Saved albums
const savedAlbums = await db.query.savedAlbum.findMany({
where: { userId },
with: {
album: {
with: {
artists: {
with: {
genres: true,
},
},
},
},
},
});
for (const sa of savedAlbums) {
if (!sa.album) continue;
for (const artist of sa.album.artists) {
this.addArtistScore(scores, artist.id, SAVED_SCORE / 2, artist);
for (const g of artist.genres) {
this.addGenreScore(scores, g.id, SAVED_SCORE / 4, g.name);
}
}
}
return scores;
}
private addTrackScore(
scores: MemberScores,
trackId: string,
score: number,
track: {
name: string | null;
artists: { id: string; name: string }[];
album?: { name: string | null } | null;
},
): void {
const existing = scores.tracks.get(trackId);
if (existing) {
existing.score += score;
} else {
scores.tracks.set(trackId, {
userId: trackId,
score,
name: track.name ?? "",
artists: track.artists.map((a) => ({ id: a.id, name: a.name })),
albumName: track.album?.name ?? undefined,
});
}
}
private addArtistScore(
scores: MemberScores,
artistId: string,
score: number,
artist: { name: string },
): void {
const existing = scores.artists.get(artistId);
if (existing) {
existing.score += score;
} else {
scores.artists.set(artistId, {
id: artistId,
name: artist.name,
score,
});
}
}
private addGenreScore(
scores: MemberScores,
genreId: string,
score: number,
genreName?: string,
): void {
const existing = scores.genreScores.get(genreId);
if (existing) {
scores.genreScores.set(genreId, existing + score);
} else {
scores.genreScores.set(genreId, score);
}
if (genreName) {
scores.genreNames.set(genreId, genreName);
}
}
private buildTrackEntityMap(
memberData: Map<string, MemberScores>,
): Map<string, TrackEntityScore> {
const entityMap = new Map<string, Map<string, number>>();
for (const [userId, data] of memberData) {
for (const [trackId, track] of data.tracks) {
let memberScores = entityMap.get(trackId);
if (!memberScores) {
memberScores = new Map();
entityMap.set(trackId, memberScores);
}
memberScores.set(userId, track.score);
}
}
const result = new Map<string, TrackEntityScore>();
for (const [trackId, memberScores] of entityMap) {
const firstData = memberData.values().next().value;
const trackInfo = firstData?.tracks.get(trackId) ?? {
name: "",
artists: [],
albumName: undefined,
};
result.set(trackId, {
id: trackId,
name: trackInfo.name,
artists: trackInfo.artists,
albumName: trackInfo.albumName,
memberScores: Array.from(memberScores.entries()).map(
([userId, score]) => ({
userId,
score,
}),
),
memberCount: memberScores.size,
});
}
return result;
}
private buildArtistEntityMap(
memberData: Map<string, MemberScores>,
): Map<string, ArtistEntityScore> {
const entityMap = new Map<string, Set<string>>();
for (const [userId, data] of memberData) {
for (const [artistId, _artistData] of data.artists) {
let members = entityMap.get(artistId);
if (!members) {
members = new Set();
entityMap.set(artistId, members);
}
members.add(userId);
}
}
const result = new Map<string, ArtistEntityScore>();
for (const [artistId, members] of entityMap) {
let _totalScore = 0;
const scoresByMember: MemberScore[] = [];
for (const userId of members) {
const data = memberData.get(userId);
const artistData = data?.artists.get(artistId);
if (artistData) {
_totalScore += artistData.score;
scoresByMember.push({ userId, score: artistData.score });
}
}
const firstData = memberData.values().next().value;
const artistInfo = firstData?.artists.get(artistId) ?? { name: "" };
result.set(artistId, {
id: artistId,
name: artistInfo.name,
memberScores: scoresByMember,
memberCount: members.size,
});
}
return result;
}
private buildGenreEntityMap(
memberData: Map<string, MemberScores>,
): Map<string, GenreEntityScore> {
const entityMap = new Map<string, Map<string, number>>();
const genreNameMap = new Map<string, string>();
for (const [userId, data] of memberData) {
for (const [genreId, score] of data.genreScores) {
let memberScores = entityMap.get(genreId);
if (!memberScores) {
memberScores = new Map();
entityMap.set(genreId, memberScores);
}
memberScores.set(userId, score);
}
for (const [genreId, name] of data.genreNames) {
if (!genreNameMap.has(genreId)) {
genreNameMap.set(genreId, name);
}
}
}
const result = new Map<string, GenreEntityScore>();
for (const [genreId, memberScores] of entityMap) {
result.set(genreId, {
id: genreId,
name: genreNameMap.get(genreId) ?? "Unknown",
memberScores: Array.from(memberScores.entries()).map(
([userId, score]) => ({
userId,
score,
}),
),
memberCount: memberScores.size,
});
}
return result;
}
private computeStoryClusters(
trackMap: Map<string, TrackEntityScore>,
artistMap: Map<string, ArtistEntityScore>,
genreMap: Map<string, GenreEntityScore>,
allMemberIds: string[],
): StoryCluster[] {
const clusterMap = new Map<string, StoryCluster>();
const keyForMembers = (members: string[]) =>
members.sort((a, b) => a.localeCompare(b)).join("|");
const getOrCreateCluster = (memberIds: string[]): StoryCluster => {
const key = keyForMembers([...memberIds]);
if (!clusterMap.has(key)) {
clusterMap.set(key, {
memberIds,
memberCount: memberIds.length,
tracks: [],
artists: [],
genres: [],
});
}
const found = clusterMap.get(key);
if (found) return found;
const fallback: StoryCluster = {
memberIds,
memberCount: memberIds.length,
tracks: [],
artists: [],
genres: [],
};
clusterMap.set(key, fallback);
return fallback;
};
// Group tracks by member subset
for (const track of trackMap.values()) {
const memberIds = track.memberScores.map((m) => m.userId);
const cluster = getOrCreateCluster(memberIds);
cluster.tracks.push(track);
}
// Group artists by member subset
for (const artist of artistMap.values()) {
const memberIds = artist.memberScores.map((m) => m.userId);
const cluster = getOrCreateCluster(memberIds);
cluster.artists.push(artist);
}
// Group genres by member subset
for (const genre of genreMap.values()) {
const memberIds = genre.memberScores.map((m) => m.userId);
const cluster = getOrCreateCluster(memberIds);
cluster.genres.push(genre);
}
// Sort clusters: everyone first, then by memberCount desc, then by totalScore desc
const clusters = Array.from(clusterMap.values());
const allMembersKey = keyForMembers([...allMemberIds]);
clusters.sort((a, b) => {
const aIsAll = keyForMembers([...a.memberIds]) === allMembersKey ? 1 : 0;
const bIsAll = keyForMembers([...b.memberIds]) === allMembersKey ? 1 : 0;
if (aIsAll !== bIsAll) return bIsAll - aIsAll;
if (a.memberCount !== b.memberCount) return b.memberCount - a.memberCount;
const aTotal = a.tracks.reduce(
(s, t) => s + t.memberScores.reduce((ss, m) => ss + m.score, 0),
0,
);
const bTotal = b.tracks.reduce(
(s, t) => s + t.memberScores.reduce((ss, m) => ss + m.score, 0),
0,
);
return bTotal - aTotal;
});
// Sort entities within each cluster
for (const cluster of clusters) {
cluster.tracks.sort((a, b) => {
const aTotal = a.memberScores.reduce((s, m) => s + m.score, 0);
const bTotal = b.memberScores.reduce((s, m) => s + m.score, 0);
return bTotal - aTotal;
});
cluster.artists.sort((a, b) => {
const aTotal = a.memberScores.reduce((s, m) => s + m.score, 0);
const bTotal = b.memberScores.reduce((s, m) => s + m.score, 0);
return bTotal - aTotal;
});
cluster.genres.sort((a, b) => {
const aTotal = a.memberScores.reduce((s, m) => s + m.score, 0);
const bTotal = b.memberScores.reduce((s, m) => s + m.score, 0);
return bTotal - aTotal;
});
}
return clusters;
}
private computePairwise(
memberData: Map<string, MemberScores>,
members: { userId: string }[],
): PairwiseComparison[] {
const pairwise: PairwiseComparison[] = [];
for (const [i, memberA] of members.entries()) {
for (const [j, memberB] of members.entries()) {
if (j <= i) continue;
const dataA = memberData.get(memberA.userId);
const dataB = memberData.get(memberB.userId);
if (!dataA || !dataB) continue;
// Shared tracks
let sharedTracks = 0;
let intersectionScore = 0;
let unionScore = 0;
const allTrackIds = new Set([
...dataA.tracks.keys(),
...dataB.tracks.keys(),
]);
for (const trackId of allTrackIds) {
const scoreA = dataA.tracks.get(trackId)?.score ?? 0;
const scoreB = dataB.tracks.get(trackId)?.score ?? 0;
if (scoreA > 0 && scoreB > 0) {
sharedTracks++;
intersectionScore += Math.min(scoreA, scoreB);
}
unionScore += Math.max(scoreA, scoreB);
}
// Shared artists
let sharedArtists = 0;
const allArtistIds = new Set([
...dataA.artists.keys(),
...dataB.artists.keys(),
]);
for (const artistId of allArtistIds) {
const scoreA = dataA.artists.get(artistId)?.score ?? 0;
const scoreB = dataB.artists.get(artistId)?.score ?? 0;
if (scoreA > 0 && scoreB > 0) {
sharedArtists++;
intersectionScore += Math.min(scoreA, scoreB);
}
unionScore += Math.max(scoreA, scoreB);
}
// Shared genres
let sharedGenres = 0;
const allGenreIds = new Set([
...dataA.genreScores.keys(),
...dataB.genreScores.keys(),
]);
for (const genreId of allGenreIds) {
const scoreA = dataA.genreScores.get(genreId) ?? 0;
const scoreB = dataB.genreScores.get(genreId) ?? 0;
if (scoreA > 0 && scoreB > 0) {
sharedGenres++;
intersectionScore += Math.min(scoreA, scoreB);
}
unionScore += Math.max(scoreA, scoreB);
}
const similarity = unionScore > 0 ? intersectionScore / unionScore : 0;
pairwise.push({
userIdA: memberA.userId,
userIdB: memberB.userId,
sharedTracks,
sharedArtists,
sharedGenres,
similarity,
});
}
}
return pairwise;
}
private buildMemberProfiles(
memberData: Map<string, MemberScores>,
): MemberProfile[] {
const profiles: MemberProfile[] = [];
for (const [userId, data] of memberData) {
const totalScore =
[...data.tracks.values()].reduce((s, t) => s + t.score, 0) +
[...data.artists.values()].reduce((s, a) => s + a.score, 0);
profiles.push({
userId,
totalScore,
genreScores: Object.fromEntries(data.genreScores),
trackCount: data.tracks.size,
artistCount: data.artists.size,
});
}
return profiles;
}
private computeGenreDiversity(
memberProfiles: MemberProfile[],
): GenreDiversity[] {
const diversity: GenreDiversity[] = [];
for (const profile of memberProfiles) {
const genreScores = profile.genreScores;
const totalScore = profile.totalScore;
if (totalScore === 0) {
diversity.push({
userId: profile.userId,
genreEntropy: 0,
totalGenreScore: 0,
});
continue;
}
let entropy = 0;
for (const score of Object.values(genreScores)) {
const p = score / totalScore;
if (p > 0) {
entropy -= p * Math.log(p);
}
}
diversity.push({
userId: profile.userId,
genreEntropy: entropy,
totalGenreScore: totalScore,
});
}
return diversity;
}
private aggregateAllGenreScores(
memberData: Map<string, MemberScores>,
): Map<string, Map<string, number>> {
const genreMap = new Map<string, Map<string, number>>();
for (const [userId, data] of memberData) {
for (const [genreId, score] of data.genreScores) {
let memberScores = genreMap.get(genreId);
if (!memberScores) {
memberScores = new Map();
genreMap.set(genreId, memberScores);
}
memberScores.set(userId, score);
}
}
return genreMap;
}
private getMostSharedGenres(
genreMap: Map<string, Map<string, number>>,
memberData: Map<string, MemberScores>,
_totalMembers: number,
): GenreEntityScore[] {
const genreNameMap = new Map<string, string>();
for (const [, data] of memberData) {
for (const [genreId, name] of data.genreNames) {
if (!genreNameMap.has(genreId)) {
genreNameMap.set(genreId, name);
}
}
}
const genres: GenreEntityScore[] = [];
for (const [genreId, memberScores] of genreMap) {
genres.push({
id: genreId,
name: genreNameMap.get(genreId) ?? "Unknown",
memberScores: Array.from(memberScores.entries()).map(
([userId, score]) => ({
userId,
score,
}),
),
memberCount: memberScores.size,
});
}
genres.sort(
(a, b) =>
b.memberCount - a.memberCount ||
b.memberScores.length - a.memberScores.length,
);
// Return top genres that are shared by at least 2 members
return genres.filter((g) => g.memberCount >= 2).slice(0, 10);
}
@DBOS.step()
private async saveAnalysis(
partyId: string,
analysis: PartyAnalysisResult,
): Promise<void> {
await db
.update(party)
.set({
analysisData: analysis,
lastUpdated: new Date(),
})
.where(sql`${party.id} = ${partyId}`);
}
}
interface MemberScores {
tracks: Map<
string,
{
userId: string;
score: number;
name: string;
artists: { id: string; name: string }[];
albumName?: string;
}
>;
artists: Map<string, { id: string; name: string; score: number }>;
genreScores: Map<string, number>;
genreNames: Map<string, string>;
}
export const partyAnalysisWorkflow = new PartyAnalysisWorkflow(
"party-analysis",
);