diff --git a/api/src/index.ts b/api/src/index.ts index ee3f361..a6836fb 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -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); diff --git a/api/src/routes/party-analysis.ts b/api/src/routes/party-analysis.ts new file mode 100644 index 0000000..7e775ff --- /dev/null +++ b/api/src/routes/party-analysis.ts @@ -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, + }, + ); diff --git a/api/src/workflows/party-analysis.ts b/api/src/workflows/party-analysis.ts new file mode 100644 index 0000000..931d241 --- /dev/null +++ b/api/src/workflows/party-analysis.ts @@ -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; + 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 { + 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 { + 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> { + const result = new Map(); + + 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 { + 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, + ): Map { + const entityMap = new Map>(); + + 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(); + + 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, + ): Map { + const entityMap = new Map>(); + + 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(); + + 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, + ): Map { + const entityMap = new Map>(); + const genreNameMap = new Map(); + + 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(); + + 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, + artistMap: Map, + genreMap: Map, + allMemberIds: string[], + ): StoryCluster[] { + const clusterMap = new Map(); + + 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, + 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, + ): 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, + ): Map> { + const genreMap = new Map>(); + + 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>, + memberData: Map, + _totalMembers: number, + ): GenreEntityScore[] { + const genreNameMap = new Map(); + 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 { + 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; + genreScores: Map; + genreNames: Map; +} + +export const partyAnalysisWorkflow = new PartyAnalysisWorkflow( + "party-analysis", +);