Skip to content

feat: save to Liked Songs and add to playlists - #403

Merged
devgianlu merged 11 commits into
devgianlu:masterfrom
isnogudus:feature/library-writes
Oct 3, 2026
Merged

devgianlu merged 11 commits into
devgianlu:masterfrom
isnogudus:feature/library-writes

Conversation

@isnogudus

@isnogudus isnogudus commented Sep 27, 2026 •

Copy link
Copy Markdown

Rebased onto master now that #402 is in; the five commits here are this PR only.

Lets clients save tracks to Liked Songs and add tracks to playlists through the daemon, via the internal collection and playlist services rather than the Web API:

  • GET /library/liked?uris=a,b tells for up to 50 tracks whether they are in Liked Songs. The daemon pages through the collection set (/collection/v2/paging), keeps only tracks (the set also holds saved albums) and reuses it for a minute; writes made through the daemon apply to it at once.
  • POST /library/liked {"uris": [...], "liked": true|false} saves or removes up to 50 tracks (/collection/v2/write).
  • POST /library/playlists/add_tracks {"playlist_uri": ..., "uris": [...]} appends tracks or episodes as an ADD change on the playlist's current revision, retrying once on 409.
  • GET /library/playlists now reports can_edit (from the rootlist's capabilities) so clients can offer only playlists the user may add to.

The collection service messages are a new proto/spotify/collection/v2/collection.proto, generated with buf like the others.

One fix was needed on the way: spclient overwrote the Content-Type of every request with a body with application/x-protobuf, but the collection service expects application/vnd.collection-v2.spotify.proto. The first commit only sets the default when the caller set none (fix: keep a caller's Content-Type on spclient requests).

Testing

  • Unit tests for the spclient requests (paths, content types, decoded bodies, conflict handling), the liked set cache (TTL, other users, local writes, failed reads) and the API validation.
  • Ran against a real account: appended a track to a scratch playlist; liked a track (set grew 1095 → 1096 with the track first), unliked it again (back to 1095); GET /library/liked answers correctly for a liked and an unliked track.
  • go mod tidy, go generate ./..., gofmt, build and go test -tags "test_unit test_integration" ./... are clean.

🤖 Generated with Claude Code

@devgianlu devgianlu left a comment •

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Left a few inline comments. The main ones: a missing liked field silently unlikes tracks, the playlist append can add tracks twice through the automatic retry, and two races in the context list cache's stale handling.

Reviewed by Claude.

Comment thread daemon/api_server.go Outdated
Comment thread spclient/library_write.go Outdated
Comment thread daemon/track_meta_cache.go
Comment thread daemon/track_meta_cache.go Outdated
Comment thread daemon/api_server.go
Comment thread daemon/liked.go Outdated
Comment thread daemon/liked.go Outdated
Comment thread daemon/liked.go Outdated
Comment thread daemon/player.go Outdated
jmt and others added 11 commits October 3, 2026 14:50
Requests with a body always had their Content-Type overwritten with
application/x-protobuf, so endpoints that expect another type could not
be reached. Only default to protobuf when the caller set nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add the collection service's WriteRequest proto and
Spclient.CollectionWrite, which adds items to or removes them from a
collection set such as Liked Songs ("collection"), using the service's
own vendor content type.

Add Spclient.PlaylistRevision and Spclient.PlaylistAppend, which appends
items as an ADD change based on the playlist's current revision and
reports a 409 as ErrPlaylistConflict.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
/library/liked saves tracks to or removes them from Liked Songs;
/library/playlists/add_tracks appends tracks or episodes to a playlist,
retrying once when the playlist changed in between. Both validate their
URIs up front and run off the player loop.

/library/playlists now reports can_edit, so clients can offer only the
playlists the user may add to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add the collection service's PageRequest/PageResponse and
Spclient.CollectionPage, which reads one page of a set such as Liked
Songs and hands back the token of the next page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Tell whether tracks are in Liked Songs. The daemon reads the whole set
through the collection service, keeps only tracks (the set also holds
saved albums) and reuses it for a minute; writes through POST
/library/liked are applied to it at once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
/context/tracks reuses a listing for five minutes, so a track appended
through /library/playlists/add_tracks, or saved to or removed from Liked
Songs through /library/liked, stayed missing from the listing a client
asks for right after. Drop the listing of the changed context after a
successful write; an enumeration already running at that moment may have
read the context before the change, so its result is not cached either.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A body without liked, or with liked: null, decoded to liked=false and
silently removed the tracks from Liked Songs with a 200, although the
spec marks the field required. Reject it with 400.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Requests went through innerRequestWith, which repeats them on network
errors and 5xx. A playlist change is not idempotent: if the server
applied an append and then answered 503/504, or the connection dropped
before the answer, the same change was sent again and the tracks landed
twice.

Add Spclient.RequestOnce, which still renews the token on 401 (the
server refused the request) but leaves other failures to the caller,
and send playlist changes through it. appendToPlaylist only resends
after a 409, which means the change was not applied.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The stale flag that kept an enumeration's possibly pre-change result out
of the cache had two holes. It was only cleared in put, so an
invalidated enumeration that failed, or an invalidation landing between
put and endFetch, left it set and the next, correct enumeration was
dropped. And put read the flag under the mutex but added to the LRU
after unlocking, while invalidate removed from the LRU before locking,
so a pre-change listing could still be cached after the invalidation.

Track the generation each in-flight enumeration started at instead:
invalidate bumps it, put caches only a result whose generation is
current, and both change the LRU under the same mutex. The generation
goes with the enumeration in endFetch, so nothing is left behind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…re gone

The collection and playlist services' 403 and 404 came back as generic
"invalid status code" errors, so the API answered 500 and logged them at
Error level; clients could not tell a permission problem from a fault.
Return a spclient.StatusError carrying the status, map 403 and 404 to
ErrForbidden and ErrNotFound for the library writes, and document both
for /library/playlists/add_tracks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GET /library/liked paged through the whole collection, albums included,
to answer for at most 50 tracks: up to 100 sequential pages, again after
every minute, holding a lock across the fetch that also delayed the 200
of a successful like, and failing with 500 on libraries past the page
cap. A track requested by its 21-character id was also reported as not
liked, since the collection holds canonical URIs.

Ask /collection/v2/contains for just the requested tracks instead, in
one round trip, with their canonical URIs; the answer still names them
as requested. The cache, its lock and the collection paging go, and
changes made on other devices show at once. The request limit and
timeout constants are renamed, as reads use them too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@isnogudus
isnogudus force-pushed the feature/library-writes branch from dd8c754 to 0a90214 Compare October 3, 2026 13:03
@isnogudus

Copy link
Copy Markdown
Author

Thanks for the thorough review. I rebased onto current master and addressed every point in its own commit on top (0012490, 852d4ba, 3c44d9e, cbcbb2c, 0a90214); replies inline. #405 and #411 build on this, I will rebase them next.

@devgianlu
devgianlu merged commit 4dbc099 into devgianlu:master Oct 3, 2026
11 checks passed
@isnogudus
isnogudus deleted the feature/library-writes branch October 3, 2026 15:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants