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,28 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { refreshDiscover } from './playlists';
vi.mock('./client', () => ({
api: { post: vi.fn() }
}));
import { api } from './client';
describe('refreshDiscover', () => {
beforeEach(() => vi.clearAllMocks());
it('POSTs the correct path and returns the response', async () => {
const sample = { playlist_id: 'abc-123', track_count: 100 };
(api.post as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(sample);
const got = await refreshDiscover();
expect(api.post).toHaveBeenCalledWith('/api/playlists/system/discover/refresh', {});
expect(got).toEqual(sample);
});
it('handles empty-library response', async () => {
const sample = { playlist_id: null, track_count: 0 };
(api.post as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(sample);
const got = await refreshDiscover();
expect(got.playlist_id).toBe(null);
expect(got.track_count).toBe(0);
});
});
@@ -1,28 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { refreshForYou } from './playlists';
vi.mock('./client', () => ({
api: { post: vi.fn() }
}));
import { api } from './client';
describe('refreshForYou', () => {
beforeEach(() => vi.clearAllMocks());
it('POSTs the correct path and returns the response', async () => {
const sample = { playlist_id: 'pl-abc', track_count: 25, track_ids: ['t1', 't2'] };
(api.post as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(sample);
const got = await refreshForYou();
expect(api.post).toHaveBeenCalledWith('/api/playlists/system/for-you/refresh', {});
expect(got).toEqual(sample);
});
it('handles empty-library response', async () => {
const sample = { playlist_id: null, track_count: 0, track_ids: [] };
(api.post as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(sample);
const got = await refreshForYou();
expect(got.playlist_id).toBe(null);
expect(got.track_ids).toEqual([]);
});
});
+18 -36
View File
@@ -21,13 +21,27 @@ export async function getPlaylist(id: string): Promise<PlaylistDetail> {
// #415 stage 2/3: rotation-aware play order for a system playlist.
// Same shape as getPlaylist but tracks are server-ordered (unplayed
// this rotation first, then heard; resets when exhausted). The model
// variant uses underscores; the route segment is hyphenated.
// this rotation first, then heard; resets when exhausted).
// Intentionally uncached — it varies per play, unlike getPlaylist.
// {variant} is the raw system_variant (#411 R2: generic by-kind).
export async function systemShuffle(variant: string): Promise<PlaylistDetail> {
const seg = variant === 'for_you' ? 'for-you' : variant;
return api.get<PlaylistDetail>(
`/api/playlists/system/${encodeURIComponent(seg)}/shuffle`,
`/api/playlists/system/${encodeURIComponent(variant)}/shuffle`,
);
}
// #411 R2: generic by-kind refresh. Rebuilds the caller's system
// playlists and returns the named kind's fresh row. Replaces the
// former per-kind refreshForYou/refreshDiscover.
export type RefreshSystemResponse = {
playlist_id: string | null;
track_count: number;
track_ids: string[];
};
export async function refreshSystem(variant: string): Promise<RefreshSystemResponse> {
return api.post<RefreshSystemResponse>(
`/api/playlists/system/${encodeURIComponent(variant)}/refresh`,
{},
);
}
@@ -91,35 +105,3 @@ export function createPlaylistQuery(id: string) {
});
}
// System playlist refresh ----------------------------------------------------
export type RefreshDiscoverResponse = {
playlist_id: string | null;
track_count: number;
};
// Re-runs the daily system playlist build for the calling user and
// returns the resulting Discover playlist's id + track count.
// Used by the detail-page Refresh button and the home tile kebab.
export async function refreshDiscover(): Promise<RefreshDiscoverResponse> {
return api.post<RefreshDiscoverResponse>('/api/playlists/system/discover/refresh', {});
}
// For-You refresh ------------------------------------------------------------
export type RefreshForYouResponse = {
playlist_id: string | null;
track_count: number;
track_ids: string[];
};
// POST /api/playlists/system/for-you/refresh — synchronously
// rebuilds the calling user's For-You playlist and returns the new
// id, track count, and track id list.
//
// Used by the home Playlists row's For-You tile play button: click
// triggers refresh, the frontend then calls getPlaylist(new_id) to
// get full TrackRef snapshots and enqueues for playback.
export async function refreshForYou(): Promise<RefreshForYouResponse> {
return api.post<RefreshForYouResponse>('/api/playlists/system/for-you/refresh', {});
}
+5 -1
View File
@@ -59,7 +59,11 @@ export type Playlist = {
description: string;
is_public: boolean;
kind: 'user' | 'system';
system_variant: 'for_you' | 'songs_like_artist' | 'discover' | null;
system_variant: string | null;
// Server-derived: a singleton system kind addressable by the
// generic /system/{kind}/{refresh,shuffle} endpoints. Drives the
// per-tile refresh affordance without the client hardcoding kinds.
refreshable: boolean;
seed_artist_id: string | null;
cover_url: string; // empty string when no cover; UI renders glyph
track_count: number;
@@ -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,
+9 -18
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, 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}
+38 -62
View File
@@ -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();
});
+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
};
}