feat(web): #415 stage 3 (web) — rotation-aware system playlist play

Web half of Stage 3. System-playlist tile play now:
- calls the new GET /api/playlists/system/{variant}/shuffle endpoint
  (rotation-aware order from the server) instead of getPlaylist;
  plays the returned order AS-IS — no client Fisher-Yates, since
  the server already ordered it. Supersedes #413's client shuffle
  for system playlists specifically; user playlists keep getPlaylist
  + stored order.
- tags the queue with the system variant. The player store carries
  _queueSource; the events dispatcher includes `source` on
  play_started so the server advances that playlist's rotation.

User playlists are unchanged (getPlaylist, plain playQueue, no
source). Tests updated: For-You play hits systemShuffle (not
getPlaylist/refresh) and passes source:for_you; user play uses
getPlaylist + plain playQueue with no source.

Flutter half is blocked — the Flutter client has no play-event
reporting at all (no /api/events POST, no scrobble), so there's no
play_started to attach `source` to. Surfacing that as a separate
decision rather than silently scope-exploding #415.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-15 07:57:00 -04:00
parent e43281d1d0
commit 179519689b
5 changed files with 91 additions and 23 deletions
+18 -9
View File
@@ -3,7 +3,7 @@
import { useQueryClient } from '@tanstack/svelte-query';
import type { Playlist, PlaylistTrack, TrackRef } from '$lib/api/types';
import { user } from '$lib/auth/store.svelte';
import { getPlaylist, refreshDiscover, refreshForYou } from '$lib/api/playlists';
import { getPlaylist, systemShuffle, refreshDiscover, refreshForYou } from '$lib/api/playlists';
import { playlistTrackToRef } from '$lib/playlists/playlistTrackToRef';
import { errCode } from '$lib/api/errors';
import { qk } from '$lib/api/queries';
@@ -45,14 +45,23 @@
if (starting) return;
starting = true;
try {
const detail = await getPlaylist(playlist.id);
const refs = toTrackRefs(detail.tracks);
if (refs.length > 0) {
// System playlists default to shuffle so replaying within a
// day feels different. User playlists keep stored order.
// Explicit refresh moved to the kebab menu — playing no
// longer rebuilds the snapshot.
playQueue(refs, 0, { shuffle: playlist.system_variant != null });
const variant = playlist.system_variant;
if (variant != null) {
// #415: server returns the rotation-aware order (unplayed
// this rotation first). Play it as-is — no client shuffle —
// and tag the queue so play_started carries `source` and
// the server advances the rotation.
const detail = await systemShuffle(variant);
const refs = toTrackRefs(detail.tracks);
if (refs.length > 0) {
playQueue(refs, 0, { source: variant });
}
} else {
const detail = await getPlaylist(playlist.id);
const refs = toTrackRefs(detail.tracks);
if (refs.length > 0) {
playQueue(refs, 0);
}
}
} finally {
starting = false;
+43 -12
View File
@@ -38,6 +38,36 @@ vi.mock('$lib/api/playlists', () => ({
}
]
} as PlaylistDetail),
systemShuffle: vi.fn().mockResolvedValue({
id: 'p-sys',
user_id: 'u-self',
owner_username: 'me',
name: 'For You',
description: '',
is_public: false,
kind: 'system',
system_variant: 'for_you',
seed_artist_id: null,
cover_url: '',
track_count: 1,
duration_sec: 60,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
tracks: [
{
position: 0,
track_id: 't-9',
album_id: 'al-1',
artist_id: 'ar-1',
title: 'Rotation Track',
artist_name: 'Test Artist',
album_title: 'Test Album',
duration_sec: 60,
stream_url: '/s/t-9',
added_at: '2026-01-01T00:00:00Z'
}
]
} as PlaylistDetail),
refreshDiscover: vi.fn().mockResolvedValue({ playlist_id: 'p-1', track_count: 5 }),
refreshForYou: vi.fn().mockResolvedValue({ playlist_id: 'p-new', track_count: 25, track_ids: ['t-1'] })
}));
@@ -228,8 +258,8 @@ describe('PlaylistCard', () => {
await waitFor(() => expect(refreshForYou).toHaveBeenCalled());
});
test('For-You play button does NOT call refreshForYou (uses existing snapshot)', async () => {
const { refreshForYou, getPlaylist } = await import('$lib/api/playlists');
test('For-You play calls systemShuffle, not getPlaylist or refreshForYou', async () => {
const { refreshForYou, getPlaylist, systemShuffle } = await import('$lib/api/playlists');
const { playQueue } = await import('$lib/player/store.svelte');
const forYouPlaylist: Playlist = {
...base,
@@ -244,13 +274,15 @@ describe('PlaylistCard', () => {
await fireEvent.click(playButton);
await new Promise(resolve => setTimeout(resolve, 10));
// Play uses the existing playlist snapshot — no refresh on press.
// #415: system play hits the rotation-aware shuffle endpoint,
// not the cached detail GET, and never refreshes on press.
expect(systemShuffle).toHaveBeenCalledWith('for_you');
expect(getPlaylist).not.toHaveBeenCalled();
expect(refreshForYou).not.toHaveBeenCalled();
expect(getPlaylist).toHaveBeenCalledWith('p-1');
expect(playQueue).toHaveBeenCalled();
});
test('For-You play passes shuffle:true to playQueue', async () => {
test('For-You play passes source:for_you to playQueue (no client shuffle)', async () => {
const { playQueue } = await import('$lib/player/store.svelte');
const forYouPlaylist: Playlist = {
...base,
@@ -266,22 +298,21 @@ describe('PlaylistCard', () => {
expect(playQueue).toHaveBeenCalledWith(
expect.any(Array),
0,
expect.objectContaining({ shuffle: true })
expect.objectContaining({ source: 'for_you' })
);
});
test('user-playlist play passes shuffle:false to playQueue', async () => {
test('user-playlist play uses getPlaylist + plain playQueue (no source)', async () => {
const { getPlaylist, systemShuffle } = await import('$lib/api/playlists');
const { playQueue } = await import('$lib/player/store.svelte');
render(PlaylistCard, { props: { playlist: base } });
const playButton = screen.getByLabelText(/Play Saturday morning/i);
await fireEvent.click(playButton);
await new Promise(resolve => setTimeout(resolve, 10));
expect(playQueue).toHaveBeenCalledWith(
expect.any(Array),
0,
expect.objectContaining({ shuffle: false })
);
expect(systemShuffle).not.toHaveBeenCalled();
expect(getPlaylist).toHaveBeenCalledWith('p-1');
expect(playQueue).toHaveBeenCalledWith(expect.any(Array), 0);
});
test('non-For-You play button does NOT call refreshForYou', async () => {