import superjson from "superjson"; import { schema, type OutputType, type SpotifyPlaylistTrack } from "./spotify-playlist_POST.schema"; import { appendSetCookies, ensureSpotifyAccessToken } from "../helpers/spotifyServer"; type PlaylistDetails = { id: string; name: string; description?: string; owner?: { display_name?: string; id?: string }; external_urls?: { spotify?: string }; images?: Array<{ url?: string }>; items?: { total?: number }; }; type PlaylistItem = { is_local?: boolean; item?: null | { id?: string; uri?: string; name?: string; duration_ms?: number; type?: string; external_urls?: { spotify?: string }; track_number?: number; disc_number?: number; external_ids?: { isrc?: string }; artists?: Array<{ name?: string }>; album?: { name?: string; images?: Array<{ url?: string }>; }; }; }; type PlaylistItemsResponse = { items?: PlaylistItem[]; next?: string | null; total?: number; }; function extractPlaylistId(input: string) { const trimmed = input.trim(); const uriMatch = trimmed.match(/^spotify:playlist:([A-Za-z0-9]+)$/); if (uriMatch) return uriMatch[1]; const urlMatch = trimmed.match(/open\.spotify\.com\/playlist\/([A-Za-z0-9]+)/); if (urlMatch) return urlMatch[1]; if (/^[A-Za-z0-9]{10,}$/.test(trimmed)) return trimmed; throw new Error("Enter a valid Spotify playlist URL."); } async function spotifyGet(url: string, accessToken: string) { const response = await fetch(url, { headers: { Authorization: `Bearer ${accessToken}` }, }); if (!response.ok) { const message = await response.text(); if (response.status === 403) { throw new Error( "Spotify only allows SpotiRip to read playlist items for playlists you own or collaborate on.", ); } throw new Error(`Spotify returned ${response.status}: ${message.slice(0, 260)}`); } return (await response.json()) as T; } export async function handle(request: Request) { try { const input = schema.parse(superjson.parse(await request.text())); const playlistId = extractPlaylistId(input.playlistUrl); const session = await ensureSpotifyAccessToken(request); if (!session.accessToken) { return new Response(superjson.stringify({ error: "Connect Spotify before importing a playlist." }), { status: 401 }); } const details = await spotifyGet( `https://api.spotify.com/v1/playlists/${encodeURIComponent(playlistId)}`, session.accessToken, ); let next: string | null = `https://api.spotify.com/v1/playlists/${encodeURIComponent(playlistId)}/items?limit=50`; const tracks: SpotifyPlaylistTrack[] = []; let reportedTotal = details.items?.total ?? 0; while (next) { const page: PlaylistItemsResponse = await spotifyGet(next, session.accessToken); reportedTotal = page.total ?? reportedTotal; for (const item of page.items ?? []) { const track = item.item; if (!track || track.type !== "track" || !track.uri) continue; tracks.push({ id: track.id || track.uri, uri: track.uri, title: track.name || "Unknown title", artist: (track.artists ?? []).map((artist) => artist.name).filter(Boolean).join(", ") || "Unknown artist", album: track.album?.name || "Unknown album", durationMs: track.duration_ms || 0, imageUrl: track.album?.images?.find((image) => image.url)?.url, externalUrl: track.external_urls?.spotify, trackNumber: track.track_number, discNumber: track.disc_number, isrc: track.external_ids?.isrc, isLocal: Boolean(item.is_local), }); } next = page.next || null; } const output: OutputType = { id: details.id, name: details.name, description: details.description, imageUrl: details.images?.find((image) => image.url)?.url, owner: details.owner?.display_name || details.owner?.id, externalUrl: details.external_urls?.spotify, total: reportedTotal || tracks.length, tracks, }; const headers = new Headers({ "Content-Type": "application/json", "Cache-Control": "no-store" }); appendSetCookies(headers, session.setCookies); return new Response(superjson.stringify(output), { headers }); } catch (error) { const message = error instanceof Error ? error.message : "Unable to import playlist."; return new Response(superjson.stringify({ error: message }), { status: 400 }); } }