From 96286eb424f1450b361d4957cb76145262edf562 Mon Sep 17 00:00:00 2001 From: Daniel Bulant Date: Sun, 24 May 2026 18:18:54 +0200 Subject: [PATCH] fix spotify playback --- api/src/auth.ts | 7 ++++- api/src/routes/spotify.ts | 31 +++++++++++++++++++++ web/src/components/party/question.tsx | 33 ++++++++++++++++++++++- web/src/hooks/use-spotify-player.ts | 39 ++++++++++++++++++++++++++- web/src/lib/eden.ts | 14 ++++++---- 5 files changed, 116 insertions(+), 8 deletions(-) diff --git a/api/src/auth.ts b/api/src/auth.ts index f8aecbb..dc561b7 100644 --- a/api/src/auth.ts +++ b/api/src/auth.ts @@ -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", ], }, diff --git a/api/src/routes/spotify.ts b/api/src/routes/spotify.ts index f82dfa0..7553541 100644 --- a/api/src/routes/spotify.ts +++ b/api/src/routes/spotify.ts @@ -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, diff --git a/web/src/components/party/question.tsx b/web/src/components/party/question.tsx index d6987d6..5044773 100644 --- a/web/src/components/party/question.tsx +++ b/web/src/components/party/question.tsx @@ -20,8 +20,12 @@ 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 { + SPOTIFY_PLAYBACK_SCOPES, + useSpotifyPlayer, +} from "#/hooks/use-spotify-player"; import { useUser } from "#/hooks/user"; +import { authClient } from "#/lib/auth-client"; import { client } from "#/lib/eden"; type PartyQuestion = NonNullable< @@ -56,6 +60,7 @@ export function Question() { const [selected, setSelected] = useState(null); const [selectedValue, setSelectedValue] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); + const [isRelinkingSpotify, setIsRelinkingSpotify] = useState(false); const question = party?.data?.currentQuestion; const spotifyTrackUri = question?.song?.platform === "spotify" && question.song.platform_id @@ -66,6 +71,7 @@ export function Question() { setEnabled: setSpotifyEnabled, status: spotifyStatus, error: spotifyError, + requiresRelink: spotifyRequiresRelink, isLoading: spotifyIsLoading, } = useSpotifyPlayer(spotifyTrackUri); const questionStartTimestamp = question?.startTimestamp ?? null; @@ -162,6 +168,18 @@ export function Question() { } } + async function handleSpotifyRelink() { + setIsRelinkingSpotify(true); + try { + await authClient.linkSocial({ + provider: "spotify", + // scopes: [...SPOTIFY_PLAYBACK_SCOPES], + }); + } finally { + setIsRelinkingSpotify(false); + } + } + return (
Question {party.data.questionIndex + 1} @@ -214,6 +232,19 @@ export function Question() { : spotifyStatus === "loading" ? " Connecting Spotify..." : null} + {spotifyRequiresRelink ? ( +
+ +
+ ) : null} diff --git a/web/src/hooks/use-spotify-player.ts b/web/src/hooks/use-spotify-player.ts index 32f92f9..6dfc2b0 100644 --- a/web/src/hooks/use-spotify-player.ts +++ b/web/src/hooks/use-spotify-player.ts @@ -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 | null = null; @@ -37,7 +60,13 @@ function loadSpotifySdk(): Promise { async function fetchSpotifyAccessToken(): Promise { 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("idle"); const [error, setError] = useState(null); + const [requiresRelink, setRequiresRelink] = useState(false); const playerRef = useRef(null); const playerPromiseRef = useRef | 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", }; diff --git a/web/src/lib/eden.ts b/web/src/lib/eden.ts index 921d08d..bd82389 100644 --- a/web/src/lib/eden.ts +++ b/web/src/lib/eden.ts @@ -1,8 +1,12 @@ import { treaty } from "@elysiajs/eden"; import type { App } from "../../../api/src/index"; -export const client = treaty("aura.rpi1.danbulant.cloud", {}); -// export const client = treaty( -// 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(apiBaseUrl, { + fetch: { credentials: "include" }, +});