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:
@@ -84,6 +84,7 @@ describe('home Playlists section', () => {
|
||||
is_public: false,
|
||||
kind: 'system',
|
||||
system_variant: 'for_you',
|
||||
refreshable: false,
|
||||
seed_artist_id: null,
|
||||
cover_url: '',
|
||||
track_count: 25,
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
deletePlaylist,
|
||||
removePlaylistTrack,
|
||||
reorderPlaylist,
|
||||
refreshDiscover
|
||||
refreshSystem
|
||||
} from '$lib/api/playlists';
|
||||
import { qk } from '$lib/api/queries';
|
||||
import { user } from '$lib/auth/store.svelte';
|
||||
@@ -130,20 +130,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
// --- Discover refresh ---
|
||||
let refreshingDiscover = $state(false);
|
||||
// --- System-playlist refresh (#411 R2: generic by-kind) ---
|
||||
let refreshingSystem = $state(false);
|
||||
|
||||
async function onRefreshDiscover() {
|
||||
refreshingDiscover = true;
|
||||
async function onRefreshSystem(variant: string) {
|
||||
refreshingSystem = true;
|
||||
try {
|
||||
await refreshDiscover();
|
||||
pushToast('Discover refreshed.');
|
||||
await refreshSystem(variant);
|
||||
pushToast('Refreshed.');
|
||||
await queryClient.invalidateQueries({ queryKey: qk.playlist(id) });
|
||||
await queryClient.invalidateQueries({ queryKey: qk.playlists() });
|
||||
} catch (e: unknown) {
|
||||
pushToast(`Refresh failed: ${errCode(e)}`, 'error');
|
||||
} finally {
|
||||
refreshingDiscover = false;
|
||||
refreshingSystem = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -177,15 +177,15 @@
|
||||
{#if !isOwner}· by {pl.owner_username}{/if}
|
||||
</p>
|
||||
</div>
|
||||
{#if pl.system_variant === 'discover'}
|
||||
{#if pl.refreshable && pl.system_variant}
|
||||
<button
|
||||
type="button"
|
||||
disabled={refreshingDiscover}
|
||||
onclick={onRefreshDiscover}
|
||||
disabled={refreshingSystem}
|
||||
onclick={() => onRefreshSystem(pl.system_variant as string)}
|
||||
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-muted hover:text-text-primary"
|
||||
aria-label="Refresh Discover"
|
||||
aria-label="Refresh playlist"
|
||||
>
|
||||
{refreshingDiscover ? 'Refreshing…' : 'Refresh'}
|
||||
{refreshingSystem ? 'Refreshing…' : 'Refresh'}
|
||||
</button>
|
||||
{/if}
|
||||
{#if isOwner}
|
||||
|
||||
@@ -19,7 +19,7 @@ vi.mock('$lib/api/playlists', () => ({
|
||||
deletePlaylist: vi.fn().mockResolvedValue(undefined),
|
||||
removePlaylistTrack: vi.fn().mockResolvedValue(undefined),
|
||||
reorderPlaylist: vi.fn().mockResolvedValue(undefined),
|
||||
refreshDiscover: vi.fn().mockResolvedValue({ playlist_id: 'p1', track_count: 5 })
|
||||
refreshSystem: vi.fn().mockResolvedValue({ playlist_id: 'p1', track_count: 5, track_ids: [] })
|
||||
}));
|
||||
|
||||
vi.mock('$lib/auth/store.svelte', () => ({
|
||||
@@ -44,13 +44,13 @@ vi.mock('$lib/api/quarantine', () => emptyQuarantineMock());
|
||||
vi.mock('$lib/api/admin/tracks', () => ({ removeTrack: vi.fn() }));
|
||||
|
||||
import PlaylistDetailPage from './+page.svelte';
|
||||
import { createPlaylistQuery, deletePlaylist, refreshDiscover } from '$lib/api/playlists';
|
||||
import { createPlaylistQuery, deletePlaylist, refreshSystem } from '$lib/api/playlists';
|
||||
|
||||
const mockedQuery = createPlaylistQuery as ReturnType<typeof vi.fn>;
|
||||
|
||||
const ownDetail: PlaylistDetail = {
|
||||
id: 'p1', user_id: 'u-self', owner_username: 'me', name: 'Mine',
|
||||
description: '', is_public: false, kind: 'user', system_variant: null, seed_artist_id: null, cover_url: '', track_count: 2, duration_sec: 274,
|
||||
description: '', is_public: false, kind: 'user', system_variant: null, refreshable: false, seed_artist_id: null, cover_url: '', track_count: 2, duration_sec: 274,
|
||||
created_at: '', updated_at: '',
|
||||
tracks: [
|
||||
{ position: 0, track_id: 't1', album_id: 'a1', artist_id: 'ar1', title: 'A', artist_name: 'X', album_title: 'Y', duration_sec: 137, stream_url: '/api/tracks/t1/stream', added_at: '' },
|
||||
@@ -87,43 +87,58 @@ describe('Playlist detail page', () => {
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('renders Refresh button only on Discover playlists', () => {
|
||||
test('renders Refresh button on a refreshable system playlist', () => {
|
||||
const discoverDetail: PlaylistDetail = {
|
||||
...ownDetail,
|
||||
kind: 'system',
|
||||
system_variant: 'discover'
|
||||
system_variant: 'discover',
|
||||
refreshable: true
|
||||
};
|
||||
mockedQuery.mockReturnValue(mockQuery({ data: discoverDetail }));
|
||||
render(PlaylistDetailPage);
|
||||
expect(screen.getByLabelText(/refresh discover/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/refresh playlist/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not render Refresh button on for_you playlists', () => {
|
||||
test('renders Refresh button on For You too (also refreshable now)', () => {
|
||||
const forYouDetail: PlaylistDetail = {
|
||||
...ownDetail,
|
||||
kind: 'system',
|
||||
system_variant: 'for_you'
|
||||
system_variant: 'for_you',
|
||||
refreshable: true
|
||||
};
|
||||
mockedQuery.mockReturnValue(mockQuery({ data: forYouDetail }));
|
||||
render(PlaylistDetailPage);
|
||||
expect(screen.queryByLabelText(/refresh discover/i)).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/refresh playlist/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not render Refresh on a non-refreshable system playlist', () => {
|
||||
const songsLike: PlaylistDetail = {
|
||||
...ownDetail,
|
||||
kind: 'system',
|
||||
system_variant: 'songs_like_artist',
|
||||
refreshable: false
|
||||
};
|
||||
mockedQuery.mockReturnValue(mockQuery({ data: songsLike }));
|
||||
render(PlaylistDetailPage);
|
||||
expect(screen.queryByLabelText(/refresh playlist/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not render Refresh button on user playlists', () => {
|
||||
mockedQuery.mockReturnValue(mockQuery({ data: ownDetail }));
|
||||
render(PlaylistDetailPage);
|
||||
expect(screen.queryByLabelText(/refresh discover/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/refresh playlist/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Refresh button calls refreshDiscover when clicked', async () => {
|
||||
test('Refresh button calls refreshSystem with the variant', async () => {
|
||||
const discoverDetail: PlaylistDetail = {
|
||||
...ownDetail,
|
||||
kind: 'system',
|
||||
system_variant: 'discover'
|
||||
system_variant: 'discover',
|
||||
refreshable: true
|
||||
};
|
||||
mockedQuery.mockReturnValue(mockQuery({ data: discoverDetail }));
|
||||
render(PlaylistDetailPage);
|
||||
await fireEvent.click(screen.getByLabelText(/refresh discover/i));
|
||||
await waitFor(() => expect(refreshDiscover).toHaveBeenCalled());
|
||||
await fireEvent.click(screen.getByLabelText(/refresh playlist/i));
|
||||
await waitFor(() => expect(refreshSystem).toHaveBeenCalledWith('discover'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@ const mockedCreatePlaylist = createPlaylist as ReturnType<typeof vi.fn>;
|
||||
function p(over: Partial<Playlist>): Playlist {
|
||||
return {
|
||||
id: 'p1', user_id: 'u-self', owner_username: 'me', name: 'A',
|
||||
description: '', is_public: false, kind: 'user', system_variant: null, seed_artist_id: null, cover_url: '', track_count: 0, duration_sec: 0,
|
||||
description: '', is_public: false, kind: 'user', system_variant: null, refreshable: false, seed_artist_id: null, cover_url: '', track_count: 0, duration_sec: 0,
|
||||
created_at: '', updated_at: '', ...over
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user