Compare commits

...

3 commits

Author SHA1 Message Date
Daniel Bulant
ff733b9774
tests 2026-05-25 18:10:22 +02:00
Daniel Bulant
7609feead0
keep playing 2026-05-24 18:36:58 +02:00
Daniel Bulant
96286eb424
fix spotify playback 2026-05-24 18:18:54 +02:00
14 changed files with 381 additions and 75 deletions

View file

@ -20,6 +20,11 @@ export const defaultSdk = SpotifyApi.withClientCredentials(
);
export const auth = betterAuth({
account: {
accountLinking: {
trustedProviders: ["spotify"],
},
},
user: {
additionalFields: {
lastSyncAt: {
@ -40,6 +45,7 @@ export const auth = betterAuth({
scope: [
"streaming",
"user-read-playback-state",
"user-read-private",
"user-read-currently-playing",
"user-modify-playback-state",
"playlist-read-private",
@ -48,7 +54,6 @@ export const auth = betterAuth({
"user-top-read",
"user-read-recently-played",
"user-library-read",
// "user-personalized",
"user-read-email",
],
},

View file

@ -69,7 +69,7 @@ export type QuizRound = {
};
export type QuizState = {
status: "running" | "results";
status: "running" | "review" | "results";
workflowId: string | null;
questionIndex: number;
currentQuestion: Question | null;

View file

@ -1,5 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import type { Question, QuizRound } from "../../party-types";
import { buildAudioMetadataQuestion } from "../audio-question-generator";
import { buildNumericQuestion } from "../numeric-question-generator";
import {
buildMemberOptions,
@ -266,6 +267,50 @@ describe("question generation", () => {
expect(question).toBeNull();
});
it("builds audio questions with fewer than four real options", async () => {
const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0.99);
const db = createFakeDb(null);
const analytics = {
storyClusters: [
{
tracks: [
{
name: "Shared Track One",
artists: [{ name: "Shared Artist One" }],
albumName: "Shared Album One",
},
{
name: "Shared Track Two",
artists: [{ name: "Shared Artist Two" }],
albumName: "Shared Album Two",
},
],
artists: [
{ name: "Shared Artist One" },
{ name: "Shared Artist Two" },
],
genres: [],
},
],
groupSummary: {
mostSharedGenres: [],
},
} as PartyAnalytics;
try {
const question = await buildAudioMetadataQuestion(db, analytics, 0, []);
expect(question).not.toBeNull();
expect(question?.type).toBe("choice");
if (question?.type === "choice") {
expect(question.options).toHaveLength(2);
expect(question.text).toContain("Shared Track Two");
}
} finally {
randomSpy.mockRestore();
}
});
it("selects a fresh party song when the current one was already used", async () => {
const db = createSongFallbackDb([
makeSong("track-1", "spotify:track:one", "One"),

View file

@ -135,7 +135,7 @@ export function getMostSharedGenreNames(analytics: PartyAnalytics): string[] {
export function pickQuestionCandidate<T extends QuestionLike>(
candidates: QuestionCandidate<T>[],
history: QuizRound[],
index: number,
_index: number,
): T | null {
const seenKeys = new Set<string>();
const seenSubjects = new Set<string>();
@ -165,7 +165,7 @@ export function pickQuestionCandidate<T extends QuestionLike>(
if (fresh.length === 0) return null;
const pool = fresh;
const candidate = pool[index % pool.length];
const candidate = pickRandom(pool);
if (!candidate) return null;
return {
...candidate.question,
@ -510,7 +510,8 @@ export function buildOrderedOptions(
const options = uniqueStrings(
values.filter((value): value is string => isUsableText(value)),
);
return options.length >= desiredCount ? options.slice(0, desiredCount) : null;
const optionCount = getAvailableOptionCount(options.length, desiredCount);
return optionCount ? options.slice(0, optionCount) : null;
}
export function buildOptionsWithCorrect(
@ -523,7 +524,8 @@ export function buildOptionsWithCorrect(
correct,
...candidates.filter((c) => isUsableText(c) && c !== correct),
]);
return options.length >= desiredCount ? options.slice(0, desiredCount) : null;
const optionCount = getAvailableOptionCount(options.length, desiredCount);
return optionCount ? options.slice(0, optionCount) : null;
}
export function pickRandom<T>(items: T[]): T | null {
@ -532,6 +534,14 @@ export function pickRandom<T>(items: T[]): T | null {
return items[index] ?? null;
}
function getAvailableOptionCount(
availableCount: number,
desiredCount: number,
): number | null {
if (availableCount < 2) return null;
return Math.min(availableCount, desiredCount);
}
export function getCurrentLeader(
quizState: { scores: Record<string, number> },
members: PartyQuestionMember[],

View file

@ -1,6 +1,15 @@
import { and, eq } from "drizzle-orm";
import Elysia from "elysia";
import { auth, betterAuthElysia } from "../auth";
import { db } from "../db";
import { account } from "../db/schema";
const SPOTIFY_PLAYBACK_SCOPES = ["streaming", "user-read-private"];
function parseScopes(scope: string | null | undefined) {
return new Set(scope?.split(/[\s,]+/).filter(Boolean) ?? []);
}
export const spotifyRoutes = new Elysia()
.use(betterAuthElysia)
@ -8,6 +17,28 @@ export const spotifyRoutes = new Elysia()
app.get(
"/token",
async ({ user, set }) => {
const [spotifyAccount] = await db
.select({ scope: account.scope })
.from(account)
.where(
and(eq(account.userId, user.id), eq(account.providerId, "spotify")),
)
.limit(1);
const grantedScopes = parseScopes(spotifyAccount?.scope);
const missingScopes = SPOTIFY_PLAYBACK_SCOPES.filter(
(scope) => !grantedScopes.has(scope),
);
if (missingScopes.length > 0) {
set.status = 403;
return {
error: "Spotify playback permission required",
code: "SPOTIFY_RELINK_REQUIRED",
missingScopes,
};
}
const token = await auth.api.getAccessToken({
body: {
userId: user.id,

View file

@ -14,6 +14,7 @@ import type {
import { partyAnalysisWorkflow } from "./party-analysis";
const TOTAL_QUESTIONS = 5;
const REVIEW_DURATION_MS = 5000;
export const quizQueue = new WorkflowQueue("quiz_queue", {
concurrency: 1,
@ -62,6 +63,7 @@ export class QuizWorkflow extends ConfiguredInstance {
}
for (let i = 0; i < TOTAL_QUESTIONS; i++) {
quizState.status = "running";
quizState.questionIndex = i;
const question = await QuizWorkflow.generateQuestion(
@ -135,9 +137,13 @@ export class QuizWorkflow extends ConfiguredInstance {
for (const [playerId, gained] of QuizWorkflow.scoreRound(round)) {
quizState.scores[playerId] = (quizState.scores[playerId] ?? 0) + gained;
const response = quizState.answers[playerId];
if (response) response.pointsGained = gained;
}
quizState.status = "review";
await QuizWorkflow.updatePartyData(partyId, quizState);
await DBOS.sleep(REVIEW_DURATION_MS);
}
// Quiz complete
@ -183,7 +189,11 @@ export class QuizWorkflow extends ConfiguredInstance {
]);
}
const noAnswers = round.responses
.filter((response) => response.selected < 0)
.map((response): [string, number] => [response.playerId, 0]);
const ordered = round.responses
.filter((response) => response.selected >= 0)
.map((response) => ({
response,
distance: Math.abs(
@ -192,6 +202,7 @@ export class QuizWorkflow extends ConfiguredInstance {
),
}))
.sort((a, b) => a.distance - b.distance);
if (ordered.length === 0) return noAnswers;
const groups: Array<{ distance: number; responses: QuizResponse[] }> = [];
for (const item of ordered) {
@ -204,13 +215,13 @@ export class QuizWorkflow extends ConfiguredInstance {
}
if (groups.length <= 1) {
return round.responses.map((response) => [
return ordered.map(({ response }) => [
response.playerId,
round.question.points,
]);
}
return groups.flatMap((group, index) => {
const scoredAnswers = groups.flatMap((group, index) => {
const factor = (groups.length - index - 1) / (groups.length - 1);
const gained = Math.round(round.question.points * factor);
return group.responses.map((response): [string, number] => [
@ -218,6 +229,8 @@ export class QuizWorkflow extends ConfiguredInstance {
gained,
]);
});
return [...scoredAnswers, ...noAnswers];
}
@DBOS.step()

View file

@ -44,7 +44,7 @@ type QuizQuestion =
};
type QuizState = {
status: "running" | "results";
status: "running" | "review" | "results";
questionIndex: number;
currentQuestion: QuizQuestion | null;
};
@ -171,7 +171,7 @@ function connectApiSocket() {
function toDeviceQuestionData(quizData: QuizState): DeviceQuestionData | null {
if (!quizData.currentQuestion) return null;
if (quizData.status === "results") return null;
if (quizData.status !== "running") return null;
const question = quizData.currentQuestion;
const q_type =
question.type === "choice"

View file

@ -1,6 +1,8 @@
import { useParty } from "#/hooks/use-party";
import { Question } from "./question";
import { QuestionReview } from "./question-review";
import { Results } from "./results";
import { SpotifyPlayback } from "./spotify-playback";
export function PartyView() {
const { party } = useParty();
@ -8,7 +10,19 @@ export function PartyView() {
switch (party.data.status) {
case "running":
return <Question />;
return (
<div className="space-y-4">
<SpotifyPlayback />
<Question />
</div>
);
case "review":
return (
<div className="space-y-4">
<SpotifyPlayback />
<QuestionReview />
</div>
);
case "results":
return <Results />;
}

View file

@ -0,0 +1,99 @@
import {
Item,
ItemContent,
ItemDescription,
ItemGroup,
ItemHeader,
ItemTitle,
} from "#/components/ui/item";
import { Section, SectionTitle } from "#/components/ui/section";
import { useParty } from "#/hooks/use-party";
import { cn } from "#/lib/utils";
type PartyQuestion = NonNullable<
NonNullable<ReturnType<typeof useParty>["party"]>["data"]["currentQuestion"]
>;
function formatAnswer(question: PartyQuestion, selected: number) {
if (selected < 0) return "No answer";
if (question.type === "numeric") return selected.toString();
return question.options[selected] ?? `Option ${selected + 1}`;
}
function formatCorrectAnswer(question: PartyQuestion) {
if (question.type === "numeric") return question.correct.toString();
return question.options[question.correct] ?? `Option ${question.correct + 1}`;
}
function formatOutcome(
question: PartyQuestion,
response: NonNullable<
NonNullable<ReturnType<typeof useParty>["party"]>["data"]["answers"][string]
> | null,
) {
if (!response || response.selected < 0) return "No answer";
if (response.correct)
return question.type === "numeric" ? "Exact" : "Correct";
if (question.type === "numeric" && response.pointsGained > 0)
return "Closest";
return "Incorrect";
}
export function QuestionReview() {
const { party, members } = useParty();
const question = party?.data?.currentQuestion;
if (!party?.data || !question) return null;
return (
<Section>
<SectionTitle>
Question {party.data.questionIndex + 1} Results
</SectionTitle>
<ItemGroup>
<Item variant="muted">
<ItemContent>
<ItemTitle>{question.text}</ItemTitle>
<ItemDescription>
Correct answer: {formatCorrectAnswer(question)}
</ItemDescription>
</ItemContent>
</Item>
{members.map((member) => {
const response = party.data.answers[member.userId];
const didAnswer = response != null && response.selected >= 0;
const isCorrect = response?.correct === true;
const outcome = formatOutcome(question, response ?? null);
return (
<Item
key={member.id}
variant={isCorrect ? "outline" : "default"}
className={cn(
isCorrect && "border-green-500/50 bg-green-500/10",
response && !isCorrect && "border-red-500/30 bg-red-500/5",
)}
>
<ItemContent>
<ItemHeader>
<ItemTitle>{member.user?.name ?? "Unknown player"}</ItemTitle>
<div className="text-sm font-medium text-foreground">
+{response?.pointsGained ?? 0}
</div>
</ItemHeader>
<ItemDescription>
{didAnswer && response
? formatAnswer(
question,
response.selectedValue ?? response.selected,
)
: "No answer"}
{didAnswer ? ` - ${outcome}` : ""}
</ItemDescription>
</ItemContent>
</Item>
);
})}
</ItemGroup>
</Section>
);
}

View file

@ -9,7 +9,6 @@ import {
ItemHeader,
ItemTitle,
} from "#/components/ui/item";
import { Label } from "#/components/ui/label";
import {
Progress,
ProgressLabel,
@ -18,9 +17,7 @@ import {
import { Section, SectionTitle } from "#/components/ui/section";
import { Slider } from "#/components/ui/slider";
import { Spinner } from "#/components/ui/spinner";
import { Switch } from "#/components/ui/switch";
import { useParty } from "#/hooks/use-party";
import { useSpotifyPlayer } from "#/hooks/use-spotify-player";
import { useUser } from "#/hooks/user";
import { client } from "#/lib/eden";
@ -57,17 +54,6 @@ export function Question() {
const [selectedValue, setSelectedValue] = useState<number | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const question = party?.data?.currentQuestion;
const spotifyTrackUri =
question?.song?.platform === "spotify" && question.song.platform_id
? `spotify:track:${question.song.platform_id}`
: null;
const {
enabled: spotifyEnabled,
setEnabled: setSpotifyEnabled,
status: spotifyStatus,
error: spotifyError,
isLoading: spotifyIsLoading,
} = useSpotifyPlayer(spotifyTrackUri);
const questionStartTimestamp = question?.startTimestamp ?? null;
const questionAnnouncement = question
? getQuestionAnnouncement(question)
@ -174,49 +160,17 @@ export function Question() {
</Item>
<Item variant="muted">
<ItemContent>
<ItemHeader>
<Progress value={progressValue}>
<ProgressLabel>
{hasResponded ? "You responded" : "Waiting on your response"}
</ProgressLabel>
<ProgressValue>{() => timeLeft}</ProgressValue>
</Progress>
</ItemHeader>
<Progress value={progressValue}>
<ProgressLabel>
{hasResponded ? "You responded" : "Waiting on your response"}
</ProgressLabel>
<ProgressValue>{() => timeLeft}</ProgressValue>
</Progress>
<ItemDescription>
{answeredCount} / {members.length} responses
</ItemDescription>
</ItemContent>
</Item>
<Item variant="muted">
<ItemContent>
<ItemHeader>
<Label htmlFor="spotify-playback" className="cursor-pointer">
Audio playback
</Label>
<Switch
id="spotify-playback"
checked={spotifyEnabled}
disabled={spotifyIsLoading || !spotifyTrackUri}
onCheckedChange={(checked) =>
void setSpotifyEnabled(checked === true)
}
/>
</ItemHeader>
<ItemDescription>
{question.hideSongTitle
? "Listen closely and guess the song."
: (question.song?.name ??
"This question has no associated song.")}
{!spotifyTrackUri
? " Spotify playback is unavailable for this question."
: spotifyError
? ` ${spotifyError}`
: spotifyStatus === "loading"
? " Connecting Spotify..."
: null}
</ItemDescription>
</ItemContent>
</Item>
{question.type === "numeric" ? (
<Item variant="muted">
<ItemContent>

View file

@ -0,0 +1,94 @@
import { useState } from "react";
import { Button } from "#/components/ui/button";
import {
Item,
ItemContent,
ItemDescription,
ItemGroup,
ItemHeader,
} from "#/components/ui/item";
import { Label } from "#/components/ui/label";
import { Switch } from "#/components/ui/switch";
import { useParty } from "#/hooks/use-party";
import { useSpotifyPlayer } from "#/hooks/use-spotify-player";
import { authClient } from "#/lib/auth-client";
export function SpotifyPlayback() {
const { party } = useParty();
const [isRelinkingSpotify, setIsRelinkingSpotify] = useState(false);
const question = party?.data?.currentQuestion;
const spotifyTrackUri =
question?.song?.platform === "spotify" && question.song.platform_id
? `spotify:track:${question.song.platform_id}`
: null;
const {
enabled: spotifyEnabled,
setEnabled: setSpotifyEnabled,
status: spotifyStatus,
error: spotifyError,
requiresRelink: spotifyRequiresRelink,
isLoading: spotifyIsLoading,
} = useSpotifyPlayer(spotifyTrackUri);
if (!question) return null;
async function handleSpotifyRelink() {
setIsRelinkingSpotify(true);
try {
await authClient.linkSocial({
provider: "spotify",
});
} finally {
setIsRelinkingSpotify(false);
}
}
return (
<ItemGroup>
<Item variant="muted">
<ItemContent>
<ItemHeader>
<Label htmlFor="spotify-playback" className="cursor-pointer">
Audio playback
</Label>
<Switch
id="spotify-playback"
checked={spotifyEnabled}
disabled={spotifyIsLoading || !spotifyTrackUri}
onCheckedChange={(checked) =>
void setSpotifyEnabled(checked === true)
}
/>
</ItemHeader>
<ItemDescription>
{question.hideSongTitle
? "Listen closely and guess the song."
: (question.song?.name ??
"This question has no associated song.")}
{!spotifyTrackUri
? " Spotify playback is unavailable for this question."
: spotifyError
? ` ${spotifyError}`
: spotifyStatus === "loading"
? " Connecting Spotify..."
: null}
{spotifyRequiresRelink ? (
<div className="mt-3">
<Button
size="sm"
onClick={() => void handleSpotifyRelink()}
disabled={isRelinkingSpotify}
>
{isRelinkingSpotify
? "Opening Spotify..."
: "Grant Spotify playback permission"}
</Button>
</div>
) : null}
</ItemDescription>
</ItemContent>
</Item>
</ItemGroup>
);
}

View file

@ -3,6 +3,29 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { client } from "#/lib/eden";
const SPOTIFY_SDK_SRC = "https://sdk.scdn.co/spotify-player.js";
export const SPOTIFY_PLAYBACK_SCOPES = ["streaming"] as const;
class SpotifyRelinkRequiredError extends Error {
constructor() {
super("Spotify needs playback permission before audio can start.");
this.name = "SpotifyRelinkRequiredError";
}
}
function isRelinkRequiredResponse(
value: unknown,
): value is { code: "SPOTIFY_RELINK_REQUIRED" } {
return (
typeof value === "object" &&
value !== null &&
"code" in value &&
value.code === "SPOTIFY_RELINK_REQUIRED"
);
}
function isRelinkRequiredError(error: unknown) {
return error instanceof SpotifyRelinkRequiredError;
}
let sdkPromise: Promise<void> | null = null;
@ -37,7 +60,13 @@ function loadSpotifySdk(): Promise<void> {
async function fetchSpotifyAccessToken(): Promise<string> {
const { data, error } = await client.api.spotify.token.get();
if (error || !data || !("accessToken" in data)) {
if (error) {
if (isRelinkRequiredResponse(error.value)) {
throw new SpotifyRelinkRequiredError();
}
throw new Error("Spotify access token is unavailable.");
}
if (!data || !("accessToken" in data)) {
throw new Error("Spotify access token is unavailable.");
}
if (!data.accessToken) {
@ -63,6 +92,7 @@ export function useSpotifyPlayer(trackUri: string | null | undefined) {
const [enabled, setEnabledState] = useState(false);
const [status, setStatus] = useState<PlaybackStatus>("idle");
const [error, setError] = useState<string | null>(null);
const [requiresRelink, setRequiresRelink] = useState(false);
const playerRef = useRef<SpotifyPlayer | null>(null);
const playerPromiseRef = useRef<Promise<SpotifyPlayer> | null>(null);
@ -86,6 +116,7 @@ export function useSpotifyPlayer(trackUri: string | null | undefined) {
if (!silent) {
setStatus("loading");
setError(null);
setRequiresRelink(false);
}
playerPromiseRef.current = (async () => {
@ -125,6 +156,7 @@ export function useSpotifyPlayer(trackUri: string | null | undefined) {
} catch (error_) {
const message = parseSpotifyError(error_);
fail(message);
setRequiresRelink(isRelinkRequiredError(error_));
setError(message);
setStatus("error");
cb("");
@ -242,6 +274,7 @@ export function useSpotifyPlayer(trackUri: string | null | undefined) {
);
}
setRequiresRelink(false);
setStatus("playing");
},
[ensurePlayer],
@ -252,6 +285,7 @@ export function useSpotifyPlayer(trackUri: string | null | undefined) {
enabledRef.current = next;
setEnabledState(next);
setError(null);
setRequiresRelink(false);
if (!next) {
setStatus("paused");
@ -270,6 +304,7 @@ export function useSpotifyPlayer(trackUri: string | null | undefined) {
setStatus("ready");
}
} catch (error_) {
setRequiresRelink(isRelinkRequiredError(error_));
setError(parseSpotifyError(error_));
setStatus("error");
}
@ -288,6 +323,7 @@ export function useSpotifyPlayer(trackUri: string | null | undefined) {
try {
await playTrack(trackUri);
} catch (error_) {
setRequiresRelink(isRelinkRequiredError(error_));
setError(parseSpotifyError(error_));
setStatus("error");
}
@ -308,6 +344,7 @@ export function useSpotifyPlayer(trackUri: string | null | undefined) {
setEnabled,
status,
error,
requiresRelink,
isLoading: status === "loading",
isReady: status === "ready" || status === "playing",
};

View file

@ -1,8 +1,12 @@
import { treaty } from "@elysiajs/eden";
import type { App } from "../../../api/src/index";
export const client = treaty<App>("aura.rpi1.danbulant.cloud", {});
// export const client = treaty<App>(
// process.env.VITE_BETTER_AUTH_URL || "127.0.0.1:3000",
// {},
// );
const apiBaseUrl =
import.meta.env.VITE_BETTER_AUTH_URL ||
(typeof window === "undefined"
? "http://127.0.0.1:3000"
: window.location.origin);
export const client = treaty<App>(apiBaseUrl, {
fetch: { credentials: "include" },
});

View file

@ -11,10 +11,10 @@ const config = defineConfig({
plugins: [
devtools(),
tailwindcss(),
tanstackStart({
spa: {
enabled: true
}
tanstackStart({
spa: {
enabled: true,
},
}),
viteReact({
babel: {
@ -22,8 +22,8 @@ const config = defineConfig({
},
}),
],
server: {
allowedHosts: ["aura.rpi1.danbulant.cloud"],
server: {
allowedHosts: ["aura.rpi1.danbulant.cloud"],
proxy: {
"/api": {
target: "http://localhost:4000",