This repository was archived by the owner on May 25, 2026. It is now read-only.
fix: correct tracks.ts schema and projection for Feb-2026 rename - #54
Merged
Merged
Conversation
Spotify's Feb-2026 deprecation rename moved the per-item track-object
key from `track` to `item` on /v1/playlists/{id}/items responses.
fetchTracksForShuffle hit the new endpoint path but still projected
and extracted against the old field name, so Spotify returned empty
objects per item and the playlist branch silently dropped every URI.
The fix mirrors the dual-accept pattern playlists.ts already uses for
the higher-level `tracks / items` metadata rename: schema accepts both
keys, projection requests `items(item(uri))`, extraction reads
`(item.item ?? item.track)?.uri`, preferring the canonical field and
falling back to the legacy alias Spotify kept during the transition
window.
/me/tracks is unaffected by the rename and continues to return `track`;
the extraction fallback handles it. Tests grow from 80 to 82 with two
new cases for the backward-compat lane and the prefer-item-over-track
defensive path.
3 tasks
Contributor
Author
|
Self-merged to unblock tonight's create-playlist backend that depends on this read-side fix. Happy to revert if review surfaces issues. |
This was referenced May 23, 2026
sudoesnothing
added a commit
that referenced
this pull request
May 23, 2026
Library module for the /api/shuffle route's write path, plus a
source-name read helper in playlists.ts. No callers in this commit;
the route lands separately. Mirrors the module patterns in
playlists.ts and tracks.ts.
createShuffledPlaylist creates an empty playlist at POST
/v1/me/playlists, then writes the URIs in sequential batches of 100
against POST /v1/playlists/{id}/items (the documented per-request
cap). Sequential over parallel preserves source order and lets a
mid-batch failure surface which chunks landed.
POST /v1/me/playlists is the post-Feb-2026 canonical path; the older
POST /v1/users/{user_id}/playlists was deprecated in the same
migration that renamed track-object keys on
/v1/playlists/{id}/items (handled read-side in #54). The /me-prefixed
path uses the token's bound identity, so no prior GET /v1/me call
is needed.
PartialPlaylistWriteError is a distinct class for the "playlist exists
but write was interrupted mid-chunk" state, carrying playlist id and
name, chunks-added and total-chunks counts, and the underlying cause.
Tests grow from 82 to 114 with 32 cases covering chunking boundaries
(0 / 100 / 101 / 199 / 200 / 250 URIs), order preservation across and
within chunks, partial-write semantics, and boundary error mapping for
401 / 429 / 5xx / other / network / response-shape on the create call.
Closes #18.
ivorycrayon
approved these changes
May 23, 2026
ivorycrayon
left a comment
Contributor
There was a problem hiding this comment.
Verified the fix end-to-end:
- Schema correctly dual-accepts
item(canonical post-Feb-2026) andtrack(deprecated alias //me/tracksshape). Both.optional().nullable()so.loose()parsing covers all three cases (canonical, transitional, liked-songs). - Projection swap in
buildInitialUrlis the load-bearing fix — olditems(track(uri))was the cause of the silent empty-object responses. - Extraction's
??order is right: canonical wins, alias is the fallback. The defensive both-fields-present test pins that. /me/trackspath untouched, which matches Spotify's migration guide scoping..loose()+.optional()means a future field addition on either branch parses cleanly.- Comment + migration-guide link give the next person enough context to know why the schema looks redundant.
Two new tests are exactly the right shape — transitional backward-compat and defensive precedence. Test count 80 → 82 with no existing tests regressed.
ivorycrayon
added a commit
that referenced
this pull request
May 23, 2026
* feat: add /api/shuffle route 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. * fix: catch 403/404 reads as unshuffleable 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). --------- Co-authored-by: ivorycrayon <ivorycrayon@users.noreply.github.com>
3 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
fetchTracksForShuffle's playlist branch was silently returning zero URIs forevery playlist.
tracks.tsnow aligns with Spotify's Feb-2026 rename of theper-item track-object key (
tracktoitem) on/v1/playlists/{id}/items:itemandtrack(both.optional().nullable()),mirroring the existing
playlists.tspattern for the higher-leveltracks/itemsmetadata rename.items(track(uri))toitems(item(uri)).(item.item ?? item.track)?.uri, preferring the canonicalfield and falling back to the deprecated alias.
Why
Spotify's Feb-2026 deprecation rename moved the per-item track-object key from
tracktoitemon/v1/playlists/{id}/itemsresponses, keepingtrackinthe OpenAPI spec as a deprecated alias during the transition window. The
existing projection requested
trackby name, which caused Spotify to return[{}, {}, {}...](empty objects per item). These parsed without error becauseof
.loose()plus.optional(), then silently dropped every URI at the?.uriundefined check. Liked Songs (/me/tracks) was not part of the renameand continues to work; the extraction fallback handles its legacy
trackshape.Verified live against the 31-track test playlist: 31 URIs returned, all
spotify:track:*, pagination chain intact. Tests grow from 80 to 82 with twonew cases for the backward-compat lane and the prefer-
item-over-trackdefensive path.