Skip to content
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
sudoesnothing merged 1 commit into
mainfrom
fix/playlist-items-track-item-rename
May 23, 2026
Merged

fix: correct tracks.ts schema and projection for Feb-2026 rename#54
sudoesnothing merged 1 commit into
mainfrom
fix/playlist-items-track-item-rename

Conversation

@sudoesnothing

Copy link
Copy Markdown
Contributor

What

fetchTracksForShuffle's playlist branch was silently returning zero URIs for
every playlist. tracks.ts now aligns with Spotify's Feb-2026 rename of the
per-item track-object key (track to item) on /v1/playlists/{id}/items:

  • Schema dual-accepts both item and track (both .optional().nullable()),
    mirroring the existing playlists.ts pattern for the higher-level
    tracks / items metadata rename.
  • Projection updated from items(track(uri)) to items(item(uri)).
  • Extraction reads (item.item ?? item.track)?.uri, preferring the canonical
    field and falling back to the deprecated alias.

Why

Spotify's Feb-2026 deprecation rename moved the per-item track-object key from
track to item on /v1/playlists/{id}/items responses, keeping track in
the OpenAPI spec as a deprecated alias during the transition window. The
existing projection requested track by name, which caused Spotify to return
[{}, {}, {}...] (empty objects per item). These parsed without error because
of .loose() plus .optional(), then silently dropped every URI at the
?.uri undefined check. Liked Songs (/me/tracks) was not part of the rename
and continues to work; the extraction fallback handles its legacy track shape.

Verified live against the 31-track test playlist: 31 URIs returned, all
spotify:track:*, pagination chain intact. Tests grow from 80 to 82 with two
new cases for the backward-compat lane and the prefer-item-over-track
defensive path.

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.
@sudoesnothing
sudoesnothing requested a review from ivorycrayon May 23, 2026 06:44
@sudoesnothing sudoesnothing self-assigned this May 23, 2026
@sudoesnothing
sudoesnothing merged commit e17901a into main May 23, 2026
1 check passed
@sudoesnothing
sudoesnothing deleted the fix/playlist-items-track-item-rename branch May 23, 2026 07:47
@sudoesnothing

Copy link
Copy Markdown
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.

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 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.

Verified the fix end-to-end:

  • Schema correctly dual-accepts item (canonical post-Feb-2026) and track (deprecated alias / /me/tracks shape). Both .optional().nullable() so .loose() parsing covers all three cases (canonical, transitional, liked-songs).
  • Projection swap in buildInitialUrl is the load-bearing fix — old items(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/tracks path 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>
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