feat: add /api/shuffle route - #58
Conversation
POST handler at src/app/api/shuffle/route.ts that orchestrates the shuffle write path. Consumes the create-playlist module landed in #56 and the read-side fetchTracksForShuffle from #54. Flow: validate POST body { playlistId }, refresh the access token via ensureFreshToken, then fetch the source URIs and source playlist name in parallel. LIKED_SONGS_ID resolves to a hard-coded "Liked Songs" name (Saved Tracks has no playlist endpoint); every other id reads GET /v1/playlists/{id}?fields=name. If the URI list comes back empty (an empty playlist, or a followed playlist post-Feb-2026 visibility restriction), the route returns 400 unshuffleable_playlist rather than create a hollow playlist. Otherwise: Fisher-Yates shuffle, then createShuffledPlaylist writes the URIs in 100-URI chunks. Hardening mirrors /api/me/playlists/route.ts: Sec-Fetch-Site cross- site refusal, runtime "nodejs", dynamic "force-dynamic", Cache-Control private no-store, Vary Cookie. Error envelope maps the boundary error classes to HTTP statuses: SpotifyUnauthorizedError destroys the session and returns 401 session_expired; SpotifyRateLimitError -> 429 rate_limited; unavailable / unexpected-status / network / response-shape / page- budget errors -> 502 upstream_unavailable or upstream_invalid; unmapped exceptions -> 500 internal_error. PartialPlaylistWriteError gets a distinct console.error log carrying playlistId, chunksAdded, totalChunks, and cause.name, then returns 502 upstream_unavailable. The partial playlist persists in the user's library; the client retries and gets a fresh one. Standard side effect of an interrupted Spotify write.
ivorycrayon
left a comment
There was a problem hiding this comment.
Route shape, error envelope, and Spotify-error mapping all match the existing /api/me/playlists pattern cleanly. Partial-write log captures the right diagnostic fields. Session destroy on token-refresh failure + 401 from Spotify is the right posture.
One mismatch worth a follow-up, not a block — the empty-URI 400 unshuffleable_playlist branch is intended to cover Feb-2026 followed-playlist content visibility (per the inline comment), but the smoke test I posted to #55 today (#55 (comment)) shows followed playlists actually return 403 Forbidden on /v1/playlists/{id}/items — not an empty success. So:
fetchTracksForShufflehits!ok→SpotifyUnexpectedStatusError- Route maps to 502
upstream_unavailable - User sees a generic Spotify-is-down message for what's actually a deliberate policy denial
The empty-URI check still catches genuinely-empty playlists (and /me/tracks if a user somehow has zero saved), so it's load-bearing for that. The followed-playlist path just doesn't take it.
Cleanest follow-up is the picker-side filter from #55's product recommendation: owner.id !== session.userId filtering at /api/me/playlists, so the user never sees a row they can't shuffle. Defense-in-depth on this route would be a specific SpotifyPlaylistInaccessibleError (403 mapped) → 400 unshuffleable_playlist, mirroring the empty-URI envelope.
Approving — landing this is the right v0 move.
The original route comment treated the empty-URI 400 envelope as the
catch for followed playlists hit by Spotify's Feb-2026 content-
visibility restriction. Smoke test against the live API today shows
Spotify returns 403 on `/v1/playlists/{id}/items` for non-owned
playlists, not an empty success — see the table in #55.
New `SpotifyPlaylistInaccessibleError` carries the raw status (403
or 404). Thrown at the boundary in both `fetchPlaylistName`
(playlists.ts) and `fetchPage` (tracks.ts) ahead of the 5xx and
catch-all checks, so the more specific case wins.
The route catches the new class and returns the same 400
`unshuffleable_playlist` envelope the empty branch already uses —
both reduce to "this playlist isn't shuffleable" from the user's
perspective. 404 covers missing or editorial ids; the picker should
never feed those in, but the defense-in-depth is cheap.
Empty-URI branch comment reworded to reflect actual mechanics.
Tests cover the 403 (followed) and 404 (missing/editorial) cases
against `fetchTracksForShuffle`'s playlist branch; `/v1/me/tracks`
is unaffected (caller owns Liked Songs by definition).
|
Pushed
Test count 114 → 116, lint/typecheck/build clean. The picker-side Hit me if you want the test additions reshaped or the error name reconsidered. |
The picker was returning every playlist from /v1/me/playlists,
including ones the caller follows but doesn't own. Post-Feb-2026
those rows can't be shuffled — /v1/playlists/{id}/items returns
403 Forbidden against non-owned, non-collaborative playlists (see
the smoke test in #55).
/api/me/playlists now fetches /v1/me in parallel with the playlist
list and Liked Songs count, then filters the playlist list to keep
only items where the caller is the owner or a collaborator. The
parallel fetch keeps the picker's first paint at one round-trip's
worth of latency. Liked Songs sits outside the filter — the caller
owns their Saved Tracks library by definition.
New `fetchCurrentUserId` helper in src/lib/spotify/me.ts follows
the same boundary pattern as the other Spotify fetches: hard-coded
origin, bounded timeout, status-coded error mapping, schema
validation. Reuses the existing error classes from playlists.ts;
the route's existing catch branches cover every failure mode the
new fetch can throw.
The defense-in-depth from #58 (`SpotifyPlaylistInaccessibleError`
→ 400 unshuffleable_playlist) remains in /api/shuffle so a race
where the user loses access between picker render and shuffle
still produces a coherent envelope.
Closes #55.
What
New POST handler at
src/app/api/shuffle/route.tsthat orchestrates the shuffle write path. Consumes the create-playlist module landed in #56 and the read-sidefetchTracksForShufflefrom #54.Flow:
{ playlistId }POST bodyensureFreshTokenfetchTracksForShuffle) and source playlist name (fetchPlaylistName) in parallel;LIKED_SONGS_IDshort-circuits to hard-coded "Liked Songs"unshuffleable_playlist(covers empty playlists and followed playlists hit by the Feb-2026 visibility restriction)createShuffledPlaylistwrites URIs in 100-URI chunksHardening matches
/api/me/playlists/route.ts: cross-siteSec-Fetch-Siterefusal,runtime = "nodejs",dynamic = "force-dynamic",Cache-Control: private, no-store,Vary: Cookie.Error envelope:
SpotifyUnauthorizedErrorreturns 401session_expiredand destroys the session;SpotifyRateLimitErrorreturns 429rate_limited; unavailable / unexpected-status / network / page-budget / response-shape errors return 502 (upstream_unavailableorupstream_invalid); unmapped exceptions return 500internal_error.PartialPlaylistWriteErrorgets a server-side log carryingplaylistId,chunksAdded,totalChunks, andcause.name, then returns 502upstream_unavailable.Why
#56 shipped the library that does the actual Spotify write. This PR mounts the HTTP surface that consumes it.
Boundary smoke against the running dev server returned the expected envelopes for 401 (no auth), 400 (invalid body), 403 (cross-site), and 405 (wrong method).
The partial-write state persists the new playlist in the user's library on interrupted writes; the client retries to get a fresh one. v0 surfaces this as a generic upstream failure until the full browser flow exercises the happy path.