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:
2026-05-15 13:21:09 -04:00
parent e3957b8eed
commit d67c0de596
21 changed files with 277 additions and 428 deletions
+1
View File
@@ -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,
+13 -13
View File
@@ -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}
+29 -14
View File
@@ -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'));
});
});
+1 -1
View File
@@ -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
};
}