refactor(playlists): #411 R2 — generic registry-driven system endpoints
Replaces the per-kind refresh/shuffle handlers with one generic
pair driven off the kind registry, in lockstep across both clients.
Server:
- systemPlaylistKind gains Singleton; RefreshableSystemKind(key)
exported. for_you/discover singleton; songs_like_artist not.
- New generic POST /api/playlists/system/{kind}/refresh and
GET /api/playlists/system/{kind}/shuffle ({kind} = raw
system_variant). Non-singleton/unknown kind → 404. Deleted
playlists_{foryou,discover}_refresh.go and the per-kind shuffle
wrappers; serveSystemPlaylistShuffle core kept.
- playlistRowView.refreshable: server-derived flag so clients show
the refresh affordance generically without hardcoding kinds.
Web (not drift-cached → uses the server flag):
- refreshSystem(variant) replaces refreshForYou/refreshDiscover;
systemShuffle drops the for_you→for-you mapping (raw variant).
- PlaylistCard + detail page gate the kebab/Refresh button on
playlist.refreshable; label is "Refresh {name}". Tests reworked;
obsolete refresh-foryou/discover api tests deleted.
Flutter (list tiles are drift-cache-sourced → derive, no migration):
- Playlist.refreshable getter = isSystem && variant !=
songs_like_artist (holds for all current + planned kinds).
- refreshSystem/systemShuffle use the raw variant; PlaylistCard +
detail screen gate kebab/Regenerate/source-tagging on refreshable
so songs_like_artist plays via get() (no by-kind endpoint).
Pure-plumbing refactor; CI verifies parity. Next (R3): the five
discovery mixes — each a candidate query + one registry entry.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@ const playlistsData = vi.hoisted(() => ({
|
||||
is_public: false,
|
||||
kind: 'user',
|
||||
system_variant: null,
|
||||
refreshable: false,
|
||||
seed_artist_id: null,
|
||||
cover_url: '',
|
||||
track_count: 3,
|
||||
@@ -30,6 +31,7 @@ const playlistsData = vi.hoisted(() => ({
|
||||
is_public: false,
|
||||
kind: 'user',
|
||||
system_variant: null,
|
||||
refreshable: false,
|
||||
seed_artist_id: null,
|
||||
cover_url: '',
|
||||
track_count: 5,
|
||||
@@ -58,6 +60,7 @@ vi.mock('$lib/api/playlists', () => ({
|
||||
is_public: false,
|
||||
kind: 'user',
|
||||
system_variant: null,
|
||||
refreshable: false,
|
||||
seed_artist_id: null,
|
||||
cover_url: '',
|
||||
track_count: 0,
|
||||
|
||||
@@ -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, systemShuffle, refreshDiscover, refreshForYou } from '$lib/api/playlists';
|
||||
import { getPlaylist, systemShuffle, refreshSystem } from '$lib/api/playlists';
|
||||
import { playlistTrackToRef } from '$lib/playlists/playlistTrackToRef';
|
||||
import { errCode } from '$lib/api/errors';
|
||||
import { qk } from '$lib/api/queries';
|
||||
@@ -13,12 +13,9 @@
|
||||
let { playlist }: { playlist: Playlist } = $props();
|
||||
|
||||
const isOwn = $derived(user.value?.id === playlist.user_id);
|
||||
const isDiscover = $derived(playlist.system_variant === 'discover');
|
||||
const isForYou = $derived(playlist.system_variant === 'for_you');
|
||||
const isSystem = $derived(playlist.system_variant != null);
|
||||
const refreshLabel = $derived(
|
||||
isDiscover ? 'Refresh Discover' : isForYou ? 'Refresh For You' : 'Refresh',
|
||||
);
|
||||
// Server tells us which system playlists support by-kind refresh
|
||||
// (#411 R2) — no hardcoded kind list here.
|
||||
const refreshable = $derived(playlist.refreshable === true);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -72,16 +69,10 @@
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
menuOpen = false;
|
||||
if (!refreshable || playlist.system_variant == null) return;
|
||||
try {
|
||||
if (isDiscover) {
|
||||
await refreshDiscover();
|
||||
pushToast('Discover refreshed.');
|
||||
} else if (isForYou) {
|
||||
await refreshForYou();
|
||||
pushToast('For You refreshed.');
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
await refreshSystem(playlist.system_variant);
|
||||
pushToast(`${playlist.name} refreshed.`);
|
||||
await queryClient.invalidateQueries({ queryKey: qk.playlist(playlist.id) });
|
||||
await queryClient.invalidateQueries({ queryKey: qk.playlists() });
|
||||
} catch (err: unknown) {
|
||||
@@ -93,7 +84,7 @@
|
||||
<svelte:window onclick={() => { menuOpen = false; }} />
|
||||
|
||||
<div class="card relative">
|
||||
{#if isSystem}
|
||||
{#if refreshable}
|
||||
<div class="kebab-wrap absolute right-1 top-1 z-10">
|
||||
<button
|
||||
type="button"
|
||||
@@ -122,7 +113,7 @@
|
||||
class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm text-text-primary hover:bg-surface-hover"
|
||||
>
|
||||
<RefreshCcw size={14} strokeWidth={1} />
|
||||
{refreshLabel}
|
||||
Refresh {playlist.name}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -17,6 +17,7 @@ vi.mock('$lib/api/playlists', () => ({
|
||||
is_public: false,
|
||||
kind: 'user',
|
||||
system_variant: null,
|
||||
refreshable: false,
|
||||
seed_artist_id: null,
|
||||
cover_url: '',
|
||||
track_count: 1,
|
||||
@@ -47,6 +48,7 @@ vi.mock('$lib/api/playlists', () => ({
|
||||
is_public: false,
|
||||
kind: 'system',
|
||||
system_variant: 'for_you',
|
||||
refreshable: true,
|
||||
seed_artist_id: null,
|
||||
cover_url: '',
|
||||
track_count: 1,
|
||||
@@ -68,8 +70,7 @@ vi.mock('$lib/api/playlists', () => ({
|
||||
}
|
||||
]
|
||||
} 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'] })
|
||||
refreshSystem: vi.fn().mockResolvedValue({ playlist_id: 'p-1', track_count: 5, track_ids: ['t-1'] })
|
||||
}));
|
||||
|
||||
vi.mock('$lib/player/store.svelte', () => ({
|
||||
@@ -92,6 +93,7 @@ const base: Playlist = {
|
||||
is_public: false,
|
||||
kind: 'user',
|
||||
system_variant: null,
|
||||
refreshable: false,
|
||||
seed_artist_id: null,
|
||||
cover_url: '',
|
||||
track_count: 12,
|
||||
@@ -173,98 +175,73 @@ describe('PlaylistCard', () => {
|
||||
expect(playQueue).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('shows kebab button on Discover playlists', () => {
|
||||
test('shows kebab on refreshable (singleton system) playlists', () => {
|
||||
const discoverPlaylist: Playlist = {
|
||||
...base,
|
||||
kind: 'system',
|
||||
system_variant: 'discover',
|
||||
refreshable: true,
|
||||
name: 'Discover'
|
||||
};
|
||||
render(PlaylistCard, { props: { playlist: discoverPlaylist } });
|
||||
expect(screen.getByLabelText(/playlist actions/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows kebab button on For You playlists', () => {
|
||||
const forYouPlaylist: Playlist = {
|
||||
...base,
|
||||
kind: 'system',
|
||||
system_variant: 'for_you',
|
||||
name: 'For You'
|
||||
};
|
||||
render(PlaylistCard, { props: { playlist: forYouPlaylist } });
|
||||
expect(screen.getByLabelText(/playlist actions/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not show kebab button on user playlists', () => {
|
||||
test('does not show kebab on user playlists', () => {
|
||||
render(PlaylistCard, { props: { playlist: base } });
|
||||
expect(screen.queryByLabelText(/playlist actions/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Refresh Discover item appears in Discover kebab', async () => {
|
||||
test('does not show kebab on a non-refreshable system playlist', () => {
|
||||
// e.g. songs_like_artist — system but multi-per-user, server
|
||||
// reports refreshable:false.
|
||||
const songsLike: Playlist = {
|
||||
...base,
|
||||
kind: 'system',
|
||||
system_variant: 'songs_like_artist',
|
||||
refreshable: false,
|
||||
name: 'Songs like X'
|
||||
};
|
||||
render(PlaylistCard, { props: { playlist: songsLike } });
|
||||
expect(screen.queryByLabelText(/playlist actions/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Refresh item appears in the kebab, labelled by name', async () => {
|
||||
const discoverPlaylist: Playlist = {
|
||||
...base,
|
||||
kind: 'system',
|
||||
system_variant: 'discover',
|
||||
refreshable: true,
|
||||
name: 'Discover'
|
||||
};
|
||||
render(PlaylistCard, { props: { playlist: discoverPlaylist } });
|
||||
const kebabBtn = screen.getByLabelText(/playlist actions/i);
|
||||
await fireEvent.click(kebabBtn);
|
||||
await fireEvent.click(screen.getByLabelText(/playlist actions/i));
|
||||
expect(screen.getByRole('menuitem', { name: /refresh discover/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Refresh For You item appears in For You kebab', async () => {
|
||||
const forYouPlaylist: Playlist = {
|
||||
...base,
|
||||
kind: 'system',
|
||||
system_variant: 'for_you',
|
||||
name: 'For You'
|
||||
};
|
||||
render(PlaylistCard, { props: { playlist: forYouPlaylist } });
|
||||
const kebabBtn = screen.getByLabelText(/playlist actions/i);
|
||||
await fireEvent.click(kebabBtn);
|
||||
expect(screen.getByRole('menuitem', { name: /refresh for you/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('clicking Refresh Discover calls refreshDiscover', async () => {
|
||||
const { refreshDiscover } = await import('$lib/api/playlists');
|
||||
test('clicking Refresh calls refreshSystem with the raw variant', async () => {
|
||||
const { refreshSystem } = await import('$lib/api/playlists');
|
||||
const discoverPlaylist: Playlist = {
|
||||
...base,
|
||||
kind: 'system',
|
||||
system_variant: 'discover',
|
||||
refreshable: true,
|
||||
name: 'Discover'
|
||||
};
|
||||
render(PlaylistCard, { props: { playlist: discoverPlaylist } });
|
||||
const kebabBtn = screen.getByLabelText(/playlist actions/i);
|
||||
await fireEvent.click(kebabBtn);
|
||||
const refreshItem = screen.getByRole('menuitem', { name: /refresh discover/i });
|
||||
await fireEvent.click(refreshItem);
|
||||
await waitFor(() => expect(refreshDiscover).toHaveBeenCalled());
|
||||
await fireEvent.click(screen.getByLabelText(/playlist actions/i));
|
||||
await fireEvent.click(screen.getByRole('menuitem', { name: /refresh discover/i }));
|
||||
await waitFor(() => expect(refreshSystem).toHaveBeenCalledWith('discover'));
|
||||
});
|
||||
|
||||
test('clicking Refresh For You calls refreshForYou', async () => {
|
||||
const { refreshForYou } = await import('$lib/api/playlists');
|
||||
const forYouPlaylist: Playlist = {
|
||||
...base,
|
||||
kind: 'system',
|
||||
system_variant: 'for_you',
|
||||
name: 'For You'
|
||||
};
|
||||
render(PlaylistCard, { props: { playlist: forYouPlaylist } });
|
||||
const kebabBtn = screen.getByLabelText(/playlist actions/i);
|
||||
await fireEvent.click(kebabBtn);
|
||||
const refreshItem = screen.getByRole('menuitem', { name: /refresh for you/i });
|
||||
await fireEvent.click(refreshItem);
|
||||
await waitFor(() => expect(refreshForYou).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
test('For-You play calls systemShuffle, not getPlaylist or refreshForYou', async () => {
|
||||
const { refreshForYou, getPlaylist, systemShuffle } = await import('$lib/api/playlists');
|
||||
test('For-You play calls systemShuffle, not getPlaylist', async () => {
|
||||
const { getPlaylist, systemShuffle } = await import('$lib/api/playlists');
|
||||
const { playQueue } = await import('$lib/player/store.svelte');
|
||||
const forYouPlaylist: Playlist = {
|
||||
...base,
|
||||
kind: 'system',
|
||||
system_variant: 'for_you',
|
||||
refreshable: true,
|
||||
name: 'For You',
|
||||
track_count: 25
|
||||
};
|
||||
@@ -274,11 +251,10 @@ describe('PlaylistCard', () => {
|
||||
await fireEvent.click(playButton);
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
// #415: system play hits the rotation-aware shuffle endpoint,
|
||||
// not the cached detail GET, and never refreshes on press.
|
||||
// #415/#411: system play hits the rotation-aware shuffle endpoint
|
||||
// (raw variant), not the cached detail GET, and never refreshes.
|
||||
expect(systemShuffle).toHaveBeenCalledWith('for_you');
|
||||
expect(getPlaylist).not.toHaveBeenCalled();
|
||||
expect(refreshForYou).not.toHaveBeenCalled();
|
||||
expect(playQueue).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -315,8 +291,8 @@ describe('PlaylistCard', () => {
|
||||
expect(playQueue).toHaveBeenCalledWith(expect.any(Array), 0);
|
||||
});
|
||||
|
||||
test('non-For-You play button does NOT call refreshForYou', async () => {
|
||||
const { refreshForYou, getPlaylist } = await import('$lib/api/playlists');
|
||||
test('user-playlist play does not refresh anything', async () => {
|
||||
const { refreshSystem, getPlaylist } = await import('$lib/api/playlists');
|
||||
const { playQueue } = await import('$lib/player/store.svelte');
|
||||
|
||||
render(PlaylistCard, { props: { playlist: base } });
|
||||
@@ -324,7 +300,7 @@ describe('PlaylistCard', () => {
|
||||
await fireEvent.click(playButton);
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
expect(refreshForYou).not.toHaveBeenCalled();
|
||||
expect(refreshSystem).not.toHaveBeenCalled();
|
||||
expect(getPlaylist).toHaveBeenCalledWith('p-1');
|
||||
expect(playQueue).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user