Skip to content
This repository was archived by the owner on May 25, 2026. It is now read-only.

feat: add /api/shuffle route - #58

Merged
ivorycrayon merged 2 commits into
mainfrom
feat/api-shuffle-route
May 23, 2026
Merged

feat: add /api/shuffle route#58
ivorycrayon merged 2 commits into
mainfrom
feat/api-shuffle-route

Conversation

@sudoesnothing

Copy link
Copy Markdown
Contributor

What

New 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 { playlistId } POST body
  • Refresh access token via ensureFreshToken
  • Fetch source URIs (fetchTracksForShuffle) and source playlist name (fetchPlaylistName) in parallel; LIKED_SONGS_ID short-circuits to hard-coded "Liked Songs"
  • Empty URI list returns 400 unshuffleable_playlist (covers empty playlists and followed playlists hit by the Feb-2026 visibility restriction)
  • Otherwise: shuffle, then createShuffledPlaylist writes URIs in 100-URI chunks

Hardening matches /api/me/playlists/route.ts: cross-site Sec-Fetch-Site refusal, runtime = "nodejs", dynamic = "force-dynamic", Cache-Control: private, no-store, Vary: Cookie.

Error envelope: SpotifyUnauthorizedError returns 401 session_expired and destroys the session; SpotifyRateLimitError returns 429 rate_limited; unavailable / unexpected-status / network / page-budget / response-shape errors return 502 (upstream_unavailable or upstream_invalid); unmapped exceptions return 500 internal_error. PartialPlaylistWriteError gets a server-side log carrying playlistId, chunksAdded, totalChunks, and cause.name, then returns 502 upstream_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.

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
ivorycrayon previously approved these changes May 23, 2026

@ivorycrayon ivorycrayon left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  • fetchTracksForShuffle hits !okSpotifyUnexpectedStatusError
  • 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).
@ivorycrayon

Copy link
Copy Markdown
Contributor

Pushed 84d1c17 to this branch — addresses the 403/404 gap I flagged in the review. Recap of what landed:

  • New SpotifyPlaylistInaccessibleError(status) in playlists.ts — carries 403 or 404 so the caller can log which branch fired
  • Thrown at the boundary in fetchPlaylistName and the playlist branch of fetchPage (tracks.ts), ahead of the 5xx + catch-all
  • Route catches it and returns 400 unshuffleable_playlist — same envelope as the empty case
  • Empty-URI comment reworded to reflect actual mechanics (was claiming it caught followed playlists; that was wrong — they 403, not empty)
  • Two tests in tracks.test.ts for 403 (followed) and 404 (missing/editorial), parallel to the existing 401/429/5xx coverage

Test count 114 → 116, lint/typecheck/build clean. The picker-side owner.id filter recommended in my #55 comment is still the right long-term fix; this is defense-in-depth on the route so unfiltered picks degrade gracefully.

Hit me if you want the test additions reshaped or the error name reconsidered.

@ivorycrayon
ivorycrayon merged commit 32880f4 into main May 23, 2026
1 check passed
@ivorycrayon
ivorycrayon deleted the feat/api-shuffle-route branch May 23, 2026 17:46
ivorycrayon added a commit that referenced this pull request May 23, 2026
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.
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants