diff --git a/API_SETUP.md b/API_SETUP.md index 6115885..cabe7df 100644 --- a/API_SETUP.md +++ b/API_SETUP.md @@ -2,7 +2,31 @@ ## Spotify -Configure `SPOTIFY_CLIENT_ID`, `SPOTIFY_CLIENT_SECRET`, and `SPOTIFY_REDIRECT_URI`. The adapter must implement playlist import with pagination before real Spotify import is enabled. +Configure: + +```env +SPOTIFY_CLIENT_ID= +SPOTIFY_CLIENT_SECRET= +SPOTIFY_REDIRECT_URI=https://your-domain.example/api/oauth/spotify/callback +``` + +Spotify Developer Dashboard steps: + +1. Create an app in the Spotify Developer Dashboard. +2. Copy the client ID into `SPOTIFY_CLIENT_ID`. +3. Copy the client secret into `SPOTIFY_CLIENT_SECRET`. +4. Add the exact redirect URI used by the deployment, including `/api/oauth/spotify/callback`. +5. Keep the redirect URI scheme, host, port, and path identical between Spotify and the environment variable. +6. Request only the scopes listed below during OAuth. + +Scopes used: + +- `playlist-read-private`: import required metadata from private playlists the user authorizes. +- `playlist-read-collaborative`: import collaborative playlist items available to the authorized user. +- `playlist-modify-private`: create private destination playlists and add tracks. +- `playlist-modify-public`: create public destination playlists if the UI later enables public export. + +Spotify is implemented as both a source and destination adapter. Public playlist import uses a server-side Spotify token when client credentials are configured. Authorized playlists and destination playlist creation require user OAuth. ## YouTube diff --git a/PLATFORM_LIMITATIONS.md b/PLATFORM_LIMITATIONS.md index a93381c..2ae7e83 100644 --- a/PLATFORM_LIMITATIONS.md +++ b/PLATFORM_LIMITATIONS.md @@ -2,7 +2,7 @@ ## Current Status -No real music provider conversion is complete yet. +Spotify source import, Spotify catalogue search, Spotify OAuth/token refresh, Spotify playlist creation, and Spotify batched add-items are implemented. ## Spotify to YouTube @@ -15,6 +15,15 @@ The initial target flow is represented in models, UI, routes, and adapter contra - Batched track insertion. - Tested failure handling. +## Spotify to Spotify + +Implemented with real Spotify credentials and user authorization. Limits still apply: + +- Spotify API rate limits can delay conversion. +- Local files, deleted playlist entries, unavailable entries, and non-track items cannot be copied through the Web API. +- Low-confidence matches are not silently selected. +- Current runtime storage is an in-memory foundation store until Supabase persistence is wired into the route layer. + ## Apple Music, Deezer, and Tidal Apple Music and Deezer are modelled for future adapters. Tidal is planned architecturally but not included in the current `MusicPlatform` enum until implementation begins. diff --git a/README.md b/README.md index 9eef37f..c6da663 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ TuneBridge is a production-oriented foundation for an accountless playlist trans - Mobile-first dark UI for required pages and result/status surfaces. - Advertising and consent placeholders without loading real advertising scripts. - Unit tests, React Testing Library tests, Playwright test, and GitHub Actions CI. +- Real Spotify source/destination adapter using the Spotify Web API for playlist import, catalogue search, playlist creation, token refresh, and batched track insertion. +- Spotify Authorization Code with PKCE endpoints, cryptographic state, short expiration, single-use validation, secure cookies, and encrypted refresh-token storage abstraction. ## Mock Functionality @@ -23,7 +25,6 @@ TuneBridge is a production-oriented foundation for an accountless playlist trans ## Planned Functionality -- Real Spotify playlist import with API pagination. - Real YouTube OAuth, catalogue search, playlist creation, and track insertion. - Apple Music, Deezer, and future Tidal adapters. - Supabase persistence for all API workflows. @@ -51,4 +52,4 @@ npm run build npm run test:e2e ``` -Do not claim real Spotify, YouTube, Apple Music, or Deezer conversion works until those provider adapters are implemented and tested. +With real Spotify credentials, Spotify playlist import, Spotify catalogue search, Spotify playlist creation, and Spotify track insertion are implemented. YouTube, Apple Music, and Deezer conversion is not implemented yet. diff --git a/SECURITY.md b/SECURITY.md index 8a2a4c2..d73e5f6 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -10,6 +10,9 @@ Implemented or scaffolded safeguards: - OAuth redirect path validation. - Secure, same-site consent cookie configuration. - Token encryption interface. +- AES-GCM token encryptor for Spotify refresh-token storage. +- Spotify OAuth state hashing, short expiration, and single-use consumption. +- Spotify token refresh and disconnect storage flow. - Rate limiting abstraction. - Secure response headers and CSP in `next.config.ts`. - Correlation IDs on JSON responses. @@ -19,7 +22,7 @@ Implemented or scaffolded safeguards: Production work still required: - Persist OAuth state hashes and enforce one-time consumption. -- Implement token encryption. +- Move OAuth state and token storage from in-memory foundation stores to Supabase. - Add Supabase RLS policies for signed anonymous sessions. - Store rate-limit counters in Supabase. - Complete provider-specific OAuth callback validation. diff --git a/next-env.d.ts b/next-env.d.ts index c4b7818..9edff1c 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/src/app/api/jobs/[jobId]/route.ts b/src/app/api/jobs/[jobId]/route.ts index 803cb42..bfc4ac7 100644 --- a/src/app/api/jobs/[jobId]/route.ts +++ b/src/app/api/jobs/[jobId]/route.ts @@ -1,4 +1,4 @@ -import { getFoundationJob } from "@/lib/api/jobs-store"; +import { getFoundationJob, getJobSummary } from "@/lib/api/jobs-store"; import { jobIdInputSchema } from "@/lib/models"; import { jsonResponse, validationError } from "@/lib/api/http"; @@ -12,7 +12,7 @@ export async function GET( if (!job) { return jsonResponse({ error: "not_found" }, 404); } - return jsonResponse({ job }); + return jsonResponse({ job, summary: getJobSummary(params.jobId) }); } catch (error) { return validationError(error); } diff --git a/src/app/api/jobs/route.ts b/src/app/api/jobs/route.ts index 00790c1..b170363 100644 --- a/src/app/api/jobs/route.ts +++ b/src/app/api/jobs/route.ts @@ -1,14 +1,20 @@ +import { convertSpotifyToSpotify } from "@/lib/adapters/spotify/conversion"; import { - createConversionJobInputSchema, - platformLabels -} from "@/lib/models"; + refreshSpotifyAccessToken, + spotifyTokenStore +} from "@/lib/adapters/spotify/token-store"; +import { createConversionJobInputSchema, platformLabels } from "@/lib/models"; import { jsonResponse, readValidatedJson, setupRequired, validationError } from "@/lib/api/http"; -import { createFoundationJob } from "@/lib/api/jobs-store"; +import { + createFoundationJob, + setFoundationJob, + setJobSummary +} from "@/lib/api/jobs-store"; import { detectMusicPlatformUrl } from "@/lib/security/url"; export async function POST(request: Request) { @@ -20,10 +26,82 @@ export async function POST(request: Request) { return jsonResponse({ error: "unsupported_url", message: detected.reason }, 400); } - if ( - detected.platform !== "spotify" || - input.destinationPlatform !== "youtube" - ) { + if (detected.platform === "spotify" && input.destinationPlatform === "spotify") { + const job = createFoundationJob({ + playlistUrl: input.url, + destinationPlatform: input.destinationPlatform + }); + const spotifySessionId = getCookieValue(request, "spotify_session"); + + if (!spotifySessionId) { + return jsonResponse( + { + job, + authorizationRequired: true, + authorizationStartUrl: `/api/oauth/spotify/start?redirectPath=/convert/${job.id}`, + message: "Authorize Spotify to create a destination playlist." + }, + 202 + ); + } + + const token = await spotifyTokenStore.get(spotifySessionId); + if (!token) { + return jsonResponse( + { + job, + authorizationRequired: true, + authorizationStartUrl: `/api/oauth/spotify/start?redirectPath=/convert/${job.id}`, + message: "Spotify authorization has expired. Reauthorize Spotify." + }, + 202 + ); + } + + const validToken = + token.expiresAt.getTime() <= Date.now() + ? await refreshSpotifyAccessToken({ anonymousSessionId: spotifySessionId }) + : token; + const started = { ...job, status: "matching" as const, currentStage: "match_tracks" as const }; + setFoundationJob(started); + const result = await convertSpotifyToSpotify({ + playlistUrl: input.url, + auth: { + accessToken: validToken.accessToken, + scopes: validToken.scopes, + expiresAt: validToken.expiresAt + } + }); + const completed = { + ...started, + destinationPlaylistId: result.destinationPlaylistUrl.split("/").pop() ?? null, + status: + result.failedTracks > 0 || result.unmatchedTracks > 0 + ? ("partially_completed" as const) + : ("completed" as const), + currentStage: "done" as const, + importedTrackCount: result.importedTracks, + matchedTrackCount: result.matchedTracks, + unmatchedTrackCount: result.unmatchedTracks, + addedTrackCount: result.addedTracks, + failedTrackCount: result.failedTracks, + updatedAt: new Date().toISOString(), + completedAt: new Date().toISOString() + }; + setFoundationJob(completed); + setJobSummary(completed.id, { + destinationPlaylistUrl: result.destinationPlaylistUrl, + importedTracks: result.importedTracks, + unavailableTracks: result.unavailableTracks, + matchedTracks: result.matchedTracks, + unmatchedTracks: result.unmatchedTracks, + addedTracks: result.addedTracks, + failedTracks: result.failedTracks + }); + return jsonResponse({ job: completed, result }, 201); + } + + if (detected.platform !== "spotify" || input.destinationPlatform !== "youtube") { return setupRequired( `The initial real-provider flow is Spotify to YouTube. ${platformLabels[detected.platform]} to ${platformLabels[input.destinationPlatform]} is planned but not implemented.` ); @@ -50,3 +128,14 @@ export async function POST(request: Request) { return validationError(error); } } + +function getCookieValue(request: Request, name: string): string | null { + return ( + request.headers + .get("cookie") + ?.split(";") + .map((value) => value.trim()) + .find((value) => value.startsWith(`${name}=`)) + ?.split("=")[1] ?? null + ); +} diff --git a/src/app/api/oauth/[platform]/callback/route.ts b/src/app/api/oauth/[platform]/callback/route.ts index 13c6e26..73c8084 100644 --- a/src/app/api/oauth/[platform]/callback/route.ts +++ b/src/app/api/oauth/[platform]/callback/route.ts @@ -1,5 +1,19 @@ import { musicPlatformSchema } from "@/lib/models"; +import { convertSpotifyToSpotify } from "@/lib/adapters/spotify/conversion"; +import { SpotifyClient } from "@/lib/adapters/spotify/client"; +import { consumeSpotifyOAuthState } from "@/lib/adapters/spotify/oauth"; +import { + createSpotifyTokenEncryptor, + spotifyTokenStore +} from "@/lib/adapters/spotify/token-store"; +import { getServerEnv } from "@/lib/config/env"; import { jsonResponse, setupRequired, validationError } from "@/lib/api/http"; +import { + getFoundationJob, + getJobSourceUrl, + setFoundationJob, + setJobSummary +} from "@/lib/api/jobs-store"; export async function GET( request: Request, @@ -16,10 +30,113 @@ export async function GET( return jsonResponse({ error: "invalid_oauth_callback" }, 400); } - return setupRequired( - `OAuth callback for ${destinationPlatform} is scaffolded. State validation and token exchange must be backed by the Supabase oauth_states table before real provider use.` - ); + if (destinationPlatform === "spotify") { + const cookieState = request.headers + .get("cookie") + ?.split(";") + .map((value) => value.trim()) + .find((value) => value.startsWith("spotify_oauth_state=")) + ?.split("=")[1]; + if (!cookieState || cookieState !== state) { + return jsonResponse({ error: "invalid_oauth_state" }, 400); + } + + const consumed = await consumeSpotifyOAuthState(state); + const env = getServerEnv(); + if (!env.SPOTIFY_REDIRECT_URI) { + return jsonResponse({ error: "spotify_setup_required" }, 501); + } + + const token = await new SpotifyClient().exchangeAuthorizationCode({ + code, + codeVerifier: consumed.codeVerifier, + redirectUri: env.SPOTIFY_REDIRECT_URI + }); + const anonymousSessionId = crypto.randomUUID(); + const encryptor = createSpotifyTokenEncryptor(); + await spotifyTokenStore.save({ + anonymousSessionId, + platform: "spotify", + encryptedRefreshToken: token.refresh_token + ? await encryptor.encrypt(token.refresh_token) + : null, + accessToken: token.access_token, + scopes: token.scope ? token.scope.split(" ") : [], + expiresAt: new Date(Date.now() + token.expires_in * 1000) + }); + + const jobId = consumed.redirectPath.match(/^\/convert\/([^/]+)$/)?.[1]; + if (jobId) { + const job = getFoundationJob(jobId); + const sourceUrl = getJobSourceUrl(jobId); + if ( + job && + sourceUrl && + job.sourcePlatform === "spotify" && + job.destinationPlatform === "spotify" + ) { + const started = { + ...job, + status: "matching" as const, + currentStage: "match_tracks" as const, + updatedAt: new Date().toISOString() + }; + setFoundationJob(started); + const result = await convertSpotifyToSpotify({ + playlistUrl: sourceUrl, + auth: { + accessToken: token.access_token, + scopes: token.scope ? token.scope.split(" ") : [], + expiresAt: new Date(Date.now() + token.expires_in * 1000) + } + }); + const completed = { + ...started, + destinationPlaylistId: + result.destinationPlaylistUrl.split("/").pop() ?? null, + status: + result.failedTracks > 0 || result.unmatchedTracks > 0 + ? ("partially_completed" as const) + : ("completed" as const), + currentStage: "done" as const, + importedTrackCount: result.importedTracks, + matchedTrackCount: result.matchedTracks, + unmatchedTrackCount: result.unmatchedTracks, + addedTrackCount: result.addedTracks, + failedTrackCount: result.failedTracks, + updatedAt: new Date().toISOString(), + completedAt: new Date().toISOString() + }; + setFoundationJob(completed); + setJobSummary(jobId, { + destinationPlaylistUrl: result.destinationPlaylistUrl, + importedTracks: result.importedTracks, + unavailableTracks: result.unavailableTracks, + matchedTracks: result.matchedTracks, + unmatchedTracks: result.unmatchedTracks, + addedTracks: result.addedTracks, + failedTracks: result.failedTracks + }); + } + } + + const response = Response.redirect(new URL(consumed.redirectPath, request.url)); + response.headers.append( + "Set-Cookie", + "spotify_oauth_state=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/" + ); + response.headers.append( + "Set-Cookie", + `spotify_session=${anonymousSessionId}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${60 * 60}${process.env.NODE_ENV === "production" ? "; Secure" : ""}` + ); + return response; + } + + return setupRequired(`OAuth callback for ${destinationPlatform} is not implemented yet.`); } catch (error) { + if (error instanceof Error && error.message.includes("configured")) { + return jsonResponse({ error: "spotify_setup_required", message: error.message }, 501); + } return validationError(error); } } diff --git a/src/app/api/oauth/[platform]/start/route.ts b/src/app/api/oauth/[platform]/start/route.ts index 5012532..e1fe140 100644 --- a/src/app/api/oauth/[platform]/start/route.ts +++ b/src/app/api/oauth/[platform]/start/route.ts @@ -1,22 +1,57 @@ -import { createCodeChallenge, createCodeVerifier, createOAuthNonce } from "@/lib/security/oauth"; -import { setupRequired, validationError } from "@/lib/api/http"; +import { createSpotifyAuthorizationUrl } from "@/lib/adapters/spotify/oauth"; +import { jsonResponse, setupRequired, validationError } from "@/lib/api/http"; import { musicPlatformSchema } from "@/lib/models"; -export async function POST( - _request: Request, +async function startSpotifyOAuth( + request: Request, context: { params: Promise<{ platform: string }> } ) { try { const { platform } = await context.params; const destinationPlatform = musicPlatformSchema.parse(platform); - const codeVerifier = createCodeVerifier(); - const codeChallenge = createCodeChallenge(codeVerifier); - const nonce = createOAuthNonce(); - return setupRequired( - `OAuth start for ${destinationPlatform} is scaffolded. Persist state hash, code verifier hash, and redirect to the provider when credentials are configured. Generated nonce ${nonce.slice(0, 8)} and PKCE challenge ${codeChallenge.slice(0, 8)} are not persisted in this foundation response.` - ); + if (destinationPlatform === "spotify") { + const url = new URL(request.url); + const redirectPath = url.searchParams.get("redirectPath") ?? "/convert"; + const authorization = await createSpotifyAuthorizationUrl({ + redirectPath, + destinationPlatform: "spotify" + }); + if (request.method === "GET") { + const redirect = Response.redirect(authorization.authorizationUrl); + redirect.headers.append( + "Set-Cookie", + `spotify_oauth_state=${authorization.state}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${10 * 60}${process.env.NODE_ENV === "production" ? "; Secure" : ""}` + ); + return redirect; + } + const response = jsonResponse({ + authorizationUrl: authorization.authorizationUrl, + scopes: [ + "playlist-read-private", + "playlist-read-collaborative", + "playlist-modify-private", + "playlist-modify-public" + ] + }); + response.cookies.set("spotify_oauth_state", authorization.state, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + maxAge: 10 * 60, + path: "/" + }); + return response; + } + + return setupRequired(`OAuth start for ${destinationPlatform} is not implemented yet.`); } catch (error) { + if (error instanceof Error && error.message.includes("not configured")) { + return jsonResponse({ error: "spotify_setup_required", message: error.message }, 501); + } return validationError(error); } } + +export const GET = startSpotifyOAuth; +export const POST = startSpotifyOAuth; diff --git a/src/app/api/playlist/preview/route.ts b/src/app/api/playlist/preview/route.ts new file mode 100644 index 0000000..4cf3dfa --- /dev/null +++ b/src/app/api/playlist/preview/route.ts @@ -0,0 +1,46 @@ +import { SpotifyAdapter } from "@/lib/adapters/spotify/adapter"; +import { extractSpotifyPlaylistId } from "@/lib/adapters/spotify/url"; +import { jsonResponse, readValidatedJson, validationError } from "@/lib/api/http"; +import { playlistUrlInputSchema } from "@/lib/models"; +import { detectMusicPlatformUrl } from "@/lib/security/url"; + +export async function POST(request: Request) { + try { + const input = await readValidatedJson(request, playlistUrlInputSchema); + const detected = detectMusicPlatformUrl(input.url); + if (!detected.supported || detected.platform !== "spotify") { + return jsonResponse({ error: "unsupported_url" }, 400); + } + + extractSpotifyPlaylistId(input.url); + const adapter = new SpotifyAdapter(); + const playlist = await adapter.importPlaylist({ playlistUrl: input.url }); + return jsonResponse({ + playlist: { + platform: "spotify", + id: playlist.sourceId, + title: playlist.title, + ownerName: playlist.ownerName, + artworkUrl: playlist.artworkUrl, + totalTracks: playlist.totalTracks, + importedTracks: playlist.tracks.length, + unavailableTracks: Math.max(0, playlist.totalTracks - playlist.tracks.length) + } + }); + } catch (error) { + if ( + error instanceof Error && + (error.message.includes("configured") || error.message.includes("access token")) + ) { + return jsonResponse( + { + error: "spotify_setup_required", + message: + "Spotify preview requires SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET." + }, + 501 + ); + } + return validationError(error); + } +} diff --git a/src/app/api/spotify/disconnect/route.ts b/src/app/api/spotify/disconnect/route.ts new file mode 100644 index 0000000..089e3de --- /dev/null +++ b/src/app/api/spotify/disconnect/route.ts @@ -0,0 +1,25 @@ +import { disconnectSpotify } from "@/lib/adapters/spotify/token-store"; +import { jsonResponse } from "@/lib/api/http"; + +export async function POST(request: Request) { + const sessionId = request.headers + .get("cookie") + ?.split(";") + .map((value) => value.trim()) + .find((value) => value.startsWith("spotify_session=")) + ?.split("=")[1]; + + if (sessionId) { + await disconnectSpotify({ anonymousSessionId: sessionId }); + } + + const response = jsonResponse({ disconnected: true }); + response.cookies.set("spotify_session", "", { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + maxAge: 0, + path: "/" + }); + return response; +} diff --git a/src/app/convert/[jobId]/page.tsx b/src/app/convert/[jobId]/page.tsx index 8c65f57..fafc490 100644 --- a/src/app/convert/[jobId]/page.tsx +++ b/src/app/convert/[jobId]/page.tsx @@ -1,6 +1,7 @@ import { AdSlot } from "@/components/ads/AdSlot"; import { ConversionStatus } from "@/components/conversion/ConversionStatus"; -import type { ConversionJob } from "@/lib/models"; +import { getFoundationJob, getJobSummary } from "@/lib/api/jobs-store"; +import type { ConversionJob, ConversionJobStatus } from "@/lib/models"; export const metadata = { title: "Conversion Status" @@ -13,14 +14,14 @@ export default async function ConversionJobPage({ }) { const { jobId } = await params; const now = new Date().toISOString(); - const job: ConversionJob = { + const job: ConversionJob = getFoundationJob(jobId) ?? { id: jobId, anonymousSessionId: "00000000-0000-4000-8000-000000000000", sourcePlatform: "spotify", destinationPlatform: "youtube", sourcePlaylistId: null, destinationPlaylistId: null, - status: "awaiting_authorization", + status: "awaiting_authorization" as ConversionJobStatus, currentStage: "authorize_destination", importedTrackCount: 0, matchedTrackCount: 0, @@ -33,6 +34,7 @@ export default async function ConversionJobPage({ errorCode: null, errorMessage: null }; + const summary = getJobSummary(jobId); return (
@@ -40,25 +42,49 @@ export default async function ConversionJobPage({

Result

-

- A destination playlist URL will appear here after real provider OAuth, - matching, playlist creation, and track insertion are implemented. -

- + {summary?.destinationPlaylistUrl ? ( + <> +

+ Spotify created one destination playlist and added matched tracks in + order. +

+ + Open Spotify playlist + + + ) : ( + <> +

+ Authorize Spotify to create a destination playlist. Real provider + conversion requires Spotify credentials and user authorization. +

+ + Authorize Spotify + + + )}

Low-confidence tracks

-

No real matches have run yet.

+

+ {summary + ? `${summary.unmatchedTracks} unmatched tracks require review.` + : "No real matches have run yet."} +

Failed tracks

-

No real track additions have run yet.

+

+ {summary + ? `${summary.failedTracks} track additions failed.` + : "No real track additions have run yet."} +

diff --git a/src/components/conversion/PlaylistUrlForm.tsx b/src/components/conversion/PlaylistUrlForm.tsx index b97ac12..3d33df7 100644 --- a/src/components/conversion/PlaylistUrlForm.tsx +++ b/src/components/conversion/PlaylistUrlForm.tsx @@ -10,6 +10,14 @@ export function PlaylistUrlForm() { const [url, setUrl] = useState(""); const [destination, setDestination] = useState("youtube"); const [message, setMessage] = useState(null); + const [preview, setPreview] = useState<{ + title: string; + ownerName: string | null; + totalTracks: number; + importedTracks: number; + unavailableTracks: number; + artworkUrl: string | null; + } | null>(null); const detected = useMemo(() => (url ? detectMusicPlatformUrl(url) : null), [url]); const demoMode = process.env.NEXT_PUBLIC_DEMO_MODE_ENABLED === "true"; @@ -21,7 +29,26 @@ export function PlaylistUrlForm() { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url, destinationPlatform: destination }) }); - const body = (await response.json()) as { message?: string; job?: { id: string } }; + const body = (await response.json()) as { + message?: string; + job?: { id: string }; + authorizationRequired?: boolean; + authorizationStartUrl?: string; + }; + if ("authorizationRequired" in body && body.authorizationStartUrl) { + const authResponse = await fetch(body.authorizationStartUrl, { method: "POST" }); + const authBody = (await authResponse.json()) as { + authorizationUrl?: string; + message?: string; + }; + if (authBody.authorizationUrl) { + window.location.href = authBody.authorizationUrl; + return; + } + setMessage(authBody.message ?? "Spotify authorization could not be started."); + return; + } + if (body.job?.id) { window.location.href = `/convert/${body.job.id}`; return; @@ -29,6 +56,26 @@ export function PlaylistUrlForm() { setMessage(body.message ?? "The playlist could not be queued."); } + async function previewPlaylist() { + setMessage("Fetching Spotify playlist preview..."); + setPreview(null); + const response = await fetch("/api/playlist/preview", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url }) + }); + const body = (await response.json()) as { + playlist?: typeof preview; + message?: string; + }; + if (body.playlist) { + setPreview(body.playlist); + setMessage(null); + return; + } + setMessage(body.message ?? "Spotify preview is unavailable."); + } + return (
{demoMode ? ( @@ -86,6 +133,28 @@ export function PlaylistUrlForm() { Start conversion