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;