Skip to content

feat(library): toggle playlist membership from track + popover#76

Merged
InstaZDLL merged 1 commit into
mainfrom
feat/playlist-popover-toggle-membership
May 20, 2026
Merged

feat(library): toggle playlist membership from track + popover#76
InstaZDLL merged 1 commit into
mainfrom
feat/playlist-popover-toggle-membership

Conversation

@InstaZDLL
Copy link
Copy Markdown
Owner

@InstaZDLL InstaZDLL commented May 20, 2026

Summary

  • Open the + popover on a track row in the Songs view: playlists that already contain the track render a green checkmark on the right, and clicking such a row removes the track instead of re-adding it (Spotify-style toggle).
  • No confirmation modal — the action is trivially reversible by re-clicking. Visual state updates optimistically; the underlying add/remove backend commands are idempotent so a failed RPC only drifts the cache until the next popover open.
  • Albums / artists / folders popovers are unchanged: they bulk-add a source and have no symmetric remove operation, so the membership UI is opt-in via a new prop.

Backend

  • New list_playlists_containing_track(track_id) -> Vec<i64> command in commands/playlist.rs. Smart playlists are excluded (their membership is rule-driven, not a toggle target).

Frontend

  • TrackTable lazy-fetches membership on first popover open per track and caches it for the table's lifetime. Toggling flips the cached set + dispatches addTracksToPlaylist or removeTrackFromPlaylist accordingly.
  • AddToPlaylistPopover gains an optional memberPlaylistIds: ReadonlySet<number> prop. When provided, matching rows render <Check> and aria-labels switch between addToPlaylistNamed / removeFromPlaylistNamed.
  • New i18n keys propagated to all 17 locales.

Test plan

  • Open + popover on a track that's already in one or more playlists → those rows show a green checkmark, others don't.
  • Click a checked row → checkmark disappears immediately, track is removed from that playlist (verify via the playlist view).
  • Click an unchecked row → checkmark appears immediately, track is added.
  • Reopen the popover for the same track → state matches the latest toggles.
  • Smart playlists never appear in the membership list, even when their rule-set would match the track.
  • Album / artist + popover still works as a plain add (no checkmarks).

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 20, 2026

Warning

Rate limit exceeded

@InstaZDLL has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 44 minutes and 31 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 617982f8-5d60-4d11-95d4-5e510be7c2c6

📥 Commits

Reviewing files that changed from the base of the PR and between 9ec111f and bcf044c.

📒 Files selected for processing (21)
  • src-tauri/src/commands/playlist.rs
  • src-tauri/src/lib.rs
  • src/components/views/LibraryView.tsx
  • src/i18n/locales/ar.json
  • src/i18n/locales/de.json
  • src/i18n/locales/en.json
  • src/i18n/locales/es.json
  • src/i18n/locales/fr.json
  • src/i18n/locales/hi.json
  • src/i18n/locales/id.json
  • src/i18n/locales/it.json
  • src/i18n/locales/ja.json
  • src/i18n/locales/kr.json
  • src/i18n/locales/nl.json
  • src/i18n/locales/pt-BR.json
  • src/i18n/locales/pt.json
  • src/i18n/locales/ru.json
  • src/i18n/locales/tr.json
  • src/i18n/locales/zh-CN.json
  • src/i18n/locales/zh-TW.json
  • src/lib/tauri/playlist.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/playlist-popover-toggle-membership

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@qodo-code-review
Copy link
Copy Markdown

qodo-code-review Bot commented May 20, 2026

Code Review by Qodo

🐞 Bugs (3) 📘 Rule violations (0)

Grey Divider


Action required

1. Stale fetch clobbers toggles 🐞 Bug ≡ Correctness
Description
In TrackTable, listPlaylistsContainingTrack() resolves asynchronously and unconditionally overwrites
trackMembership for the track, so a late response can erase optimistic add/remove flips (or race
with multiple in-flight fetches) and render incorrect checkmarks/toggle behavior.
Code

src/components/views/LibraryView.tsx[R1230-1242]

Evidence
The popover open handler starts a lazy async fetch and then writes the fetched IDs into
trackMembership, while the click handler performs optimistic set mutations. Because the fetch
completion overwrites the per-track Set, it can erase optimistic changes made after the fetch
started.

src/components/views/LibraryView.tsx[1224-1250]
src/components/views/LibraryView.tsx[1268-1288]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`listPlaylistsContainingTrack(track.id)` writes `next.set(track.id, new Set(ids))` when it resolves, regardless of whether the user already toggled membership for that track while the request was in flight. This can clobber optimistic updates and/or produce an incorrect cached membership set.

## Issue Context
The race is possible whenever the user opens the `+` popover and clicks a playlist before the membership fetch returns, or when the popover is opened/closed/reopened quickly (triggering multiple concurrent fetches).

## Fix Focus Areas
- src/components/views/LibraryView.tsx[1224-1290]

## Implementation direction
- Track per-track in-flight requests (e.g., `membershipFetchVersion: Map<trackId, number>` or `inFlight: Set<trackId>`).
- When a fetch resolves, update state only if it’s still the latest request for that track.
- Merge fetched IDs with any optimistic mutations that occurred during the request (e.g., record pending ops per track while loading, then apply them to the fetched `Set` before committing).
- Prevent duplicate fetches for the same track while one is already in flight.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Smart playlists become toggleable 🐞 Bug ≡ Correctness
Description
Although the backend membership API excludes smart playlists, the frontend optimistic membership set
can still add smart playlist IDs, causing smart playlists to show checkmarks and later route clicks
to removeTrackFromPlaylist, contradicting the intended “non-toggle target” behavior.
Code

src/components/views/LibraryView.tsx[R1269-1288]

Evidence
The backend membership query excludes smart playlists, but the frontend’s optimistic Set mutation
does not, and the UI renders checkmarks purely from that Set. Smart playlists are regenerated by
wiping playlist_track rows, reinforcing that they shouldn’t be treated as stable toggle targets.

src-tauri/src/commands/playlist.rs[419-442]
src/components/views/LibraryView.tsx[1264-1288]
src/components/views/LibraryView.tsx[1354-1386]
src/lib/tauri/playlist.ts[9-34]
src-tauri/src/smart_playlists/custom.rs[434-470]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Smart playlists are excluded server-side from `list_playlists_containing_track`, but the client-side optimistic update adds/removes *any* `playlistId` into `trackMembership`. This allows smart playlists to gain checkmarks and be treated as toggle targets (including calling remove), which contradicts the feature intent.

## Issue Context
- Backend explicitly filters `p.is_smart = 0` for the membership list.
- Frontend uses `memberPlaylistIds` both to render checkmarks and to decide add vs remove.
- Smart playlists are re-materialized by deleting and recreating `playlist_track` rows, so manual toggles are not durable.

## Fix Focus Areas
- src/components/views/LibraryView.tsx[1264-1387]
- src-tauri/src/commands/playlist.rs[419-442]
- src/lib/tauri/playlist.ts[9-34]

## Implementation direction
- Ensure smart playlists never render a membership checkmark: in `AddToPlaylistPopover`, compute `isMember` as `!pl.is_smart && (memberPlaylistIds?.has(pl.id) ?? false)`.
- In `TrackTable.onPick`, look up the playlist by id (from `playlists`) and:
 - If `pl.is_smart`, always call the add handler and **do not** mutate `trackMembership` (so smart playlists can’t become “members” in the local Set).
 - Otherwise keep current toggle/optimistic behavior.
- Optionally: consider filtering smart playlists out of the list entirely when `memberPlaylistIds` is provided, if the product intent is “not a target at all.”

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

3. Missing track_id index 🐞 Bug ➹ Performance
Description
The new list_playlists_containing_track query filters playlist_track by track_id, but the schema has
no index starting with track_id, so this can devolve into a playlist_track table scan and make
popover opens slow at scale.
Code

src-tauri/src/commands/playlist.rs[R431-437]

Evidence
The new command introduces a track_id-based lookup on playlist_track, while the schema shows only a
PK ordered by playlist_id first and an index on (playlist_id, position), so the new access pattern
is not indexed.

src-tauri/src/commands/playlist.rs[419-441]
src-tauri/migrations/profile/20260411120000_initial.sql[221-245]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`list_playlists_containing_track` queries `playlist_track` with `WHERE pt.track_id = ?`. The current schema indexes `(playlist_id, position)` and uses PK `(playlist_id, track_id)`, neither of which supports efficient lookups by `track_id`.

## Issue Context
This command is called on popover open; without an index, performance degrades linearly with total playlist-track rows.

## Fix Focus Areas
- src-tauri/src/commands/playlist.rs[426-441]
- src-tauri/migrations/profile/20260411120000_initial.sql[221-245]

## Implementation direction
- Add a new migration creating an index such as:
 - `CREATE INDEX idx_playlist_track_track_id ON playlist_track(track_id);`
 - (or `CREATE INDEX idx_playlist_track_track_id_playlist_id ON playlist_track(track_id, playlist_id);` if you want to optimize both filter and output)
- Keep the existing PK and position index unchanged.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

@github-actions github-actions Bot added scope: frontend React/Vite frontend (src/) scope: backend Rust/Tauri backend (src-tauri/) scope: i18n Translations (src/i18n/) type: feat New feature size: l 200-500 lines labels May 20, 2026
@InstaZDLL InstaZDLL self-assigned this May 20, 2026
Open the `+` popover on a track row and rows for playlists that already
contain the track now render a green checkmark; clicking such a row
removes the track from that playlist instead of re-adding it. Spotify-
style toggle, no confirmation modal (action is trivially reversible).

Backend
- New `list_playlists_containing_track(track_id) -> Vec<i64>` command,
  excludes smart playlists (rule-driven, not a toggle target).

Frontend
- TrackTable lazy-fetches membership the first time a track's popover
  opens, caches it for the table's lifetime, and flips the cached set
  optimistically on click.
- AddToPlaylistPopover gains an optional `memberPlaylistIds` prop to
  render the checkmark + adjust each row's aria-label. Album / artist
  / folder popovers omit the prop — they bulk-add and have no symmetric
  remove semantic.
- New i18n keys `trackActions.addToPlaylistNamed` / `removeFromPlaylistNamed`
  propagated to all 17 locales.
@InstaZDLL InstaZDLL force-pushed the feat/playlist-popover-toggle-membership branch from ee007e9 to bcf044c Compare May 20, 2026 19:55
@codacy-production
Copy link
Copy Markdown

codacy-production Bot commented May 20, 2026

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 10 complexity · 0 duplication

Metric Results
Complexity 10
Duplication 0

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@InstaZDLL InstaZDLL merged commit 24d0037 into main May 20, 2026
14 checks passed
@InstaZDLL InstaZDLL deleted the feat/playlist-popover-toggle-membership branch May 20, 2026 19:59
Comment on lines +1230 to +1242
const opening = !isMenuOpen;
setOpenMenuTrackId(opening ? track.id : null);
// Lazy-fetch membership the first time this track's
// popover is opened. Subsequent opens reuse the cached
// set (kept in sync via optimistic updates on toggle).
if (opening && !trackMembership.has(track.id)) {
listPlaylistsContainingTrack(track.id)
.then((ids) => {
setTrackMembership((prev) => {
const next = new Map(prev);
next.set(track.id, new Set(ids));
return next;
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

1. Stale fetch clobbers toggles 🐞 Bug ≡ Correctness

In TrackTable, listPlaylistsContainingTrack() resolves asynchronously and unconditionally overwrites
trackMembership for the track, so a late response can erase optimistic add/remove flips (or race
with multiple in-flight fetches) and render incorrect checkmarks/toggle behavior.
Agent Prompt
## Issue description
`listPlaylistsContainingTrack(track.id)` writes `next.set(track.id, new Set(ids))` when it resolves, regardless of whether the user already toggled membership for that track while the request was in flight. This can clobber optimistic updates and/or produce an incorrect cached membership set.

## Issue Context
The race is possible whenever the user opens the `+` popover and clicks a playlist before the membership fetch returns, or when the popover is opened/closed/reopened quickly (triggering multiple concurrent fetches).

## Fix Focus Areas
- src/components/views/LibraryView.tsx[1224-1290]

## Implementation direction
- Track per-track in-flight requests (e.g., `membershipFetchVersion: Map<trackId, number>` or `inFlight: Set<trackId>`).
- When a fetch resolves, update state only if it’s still the latest request for that track.
- Merge fetched IDs with any optimistic mutations that occurred during the request (e.g., record pending ops per track while loading, then apply them to the fetched `Set` before committing).
- Prevent duplicate fetches for the same track while one is already in flight.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +1269 to +1288
const members = trackMembership.get(track.id);
const isMember = members?.has(playlistId) ?? false;
// Optimistic membership flip — the underlying mutations
// are idempotent on the backend, so a failed RPC just
// means the visual state will drift until the next
// popover open, which is the worst-case loss for a
// single click.
setTrackMembership((prev) => {
const next = new Map(prev);
const set = new Set(next.get(track.id) ?? []);
if (isMember) set.delete(playlistId);
else set.add(playlistId);
next.set(track.id, set);
return next;
});
if (isMember) {
void onRemoveFromPlaylist(playlistId, track.id);
} else {
void onAddToPlaylist(playlistId, track.id);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

2. Smart playlists become toggleable 🐞 Bug ≡ Correctness

Although the backend membership API excludes smart playlists, the frontend optimistic membership set
can still add smart playlist IDs, causing smart playlists to show checkmarks and later route clicks
to removeTrackFromPlaylist, contradicting the intended “non-toggle target” behavior.
Agent Prompt
## Issue description
Smart playlists are excluded server-side from `list_playlists_containing_track`, but the client-side optimistic update adds/removes *any* `playlistId` into `trackMembership`. This allows smart playlists to gain checkmarks and be treated as toggle targets (including calling remove), which contradicts the feature intent.

## Issue Context
- Backend explicitly filters `p.is_smart = 0` for the membership list.
- Frontend uses `memberPlaylistIds` both to render checkmarks and to decide add vs remove.
- Smart playlists are re-materialized by deleting and recreating `playlist_track` rows, so manual toggles are not durable.

## Fix Focus Areas
- src/components/views/LibraryView.tsx[1264-1387]
- src-tauri/src/commands/playlist.rs[419-442]
- src/lib/tauri/playlist.ts[9-34]

## Implementation direction
- Ensure smart playlists never render a membership checkmark: in `AddToPlaylistPopover`, compute `isMember` as `!pl.is_smart && (memberPlaylistIds?.has(pl.id) ?? false)`.
- In `TrackTable.onPick`, look up the playlist by id (from `playlists`) and:
  - If `pl.is_smart`, always call the add handler and **do not** mutate `trackMembership` (so smart playlists can’t become “members” in the local Set).
  - Otherwise keep current toggle/optimistic behavior.
- Optionally: consider filtering smart playlists out of the list entirely when `memberPlaylistIds` is provided, if the product intent is “not a target at all.”

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

scope: backend Rust/Tauri backend (src-tauri/) scope: frontend React/Vite frontend (src/) scope: i18n Translations (src/i18n/) size: l 200-500 lines type: feat New feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant