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
@@ -212,7 +212,7 @@ class _Body extends ConsumerWidget {
ref.read(playerActionsProvider).playTracks(
playableRefs,
initialIndex: startIdx >= 0 ? startIdx : 0,
source: detail.playlist.isSystem
source: detail.playlist.refreshable
? detail.playlist.systemVariant
: null,
);
@@ -266,7 +266,7 @@ class _Header extends ConsumerWidget {
),
const Spacer(),
if (playable.isNotEmpty) ...[
if (p.isSystem)
if (p.refreshable)
OutlinedButton.icon(
key: const Key('regenerate_playlist_button'),
onPressed: () => _regenerate(context, ref),
@@ -298,7 +298,7 @@ class _Header extends ConsumerWidget {
final refs = playable.map(_toTrackRef).toList(growable: false);
ref.read(playerActionsProvider).playTracks(
refs,
source: p.isSystem ? p.systemVariant : null,
source: p.refreshable ? p.systemVariant : null,
);
},
icon: const Icon(Icons.play_arrow),
@@ -69,7 +69,7 @@ class PlaylistCard extends ConsumerWidget {
// System playlists get a refresh affordance so the
// user can force a fresh mix instead of waiting for
// the daily 03:00 rebuild. Mirrors the web kebab.
if (playlist.isSystem)
if (playlist.refreshable)
Positioned(
top: 2,
right: 2,
@@ -123,11 +123,7 @@ class PlaylistCard extends ConsumerWidget {
);
}
String get _refreshLabel => switch (playlist.systemVariant) {
'for_you' => 'Refresh For You',
'discover' => 'Refresh Discover',
_ => 'Refresh',
};
String get _refreshLabel => 'Refresh ${playlist.name}';
/// Forces a server-side rebuild of this system playlist, then
/// invalidates the aggregate list so the rotated UUID + new tracks
@@ -155,11 +151,12 @@ class PlaylistCard extends ConsumerWidget {
/// index 0. Mirrors the web PlaylistCard's onPlayClick.
Future<void> _playPlaylist(WidgetRef ref) async {
final api = await ref.read(playlistsApiProvider.future);
// System playlists: fetch the server's rotation-aware order
// (#415) and play it as-is, tagged with the source so the
// play-events reporter advances that playlist's rotation. User
// playlists keep stored order, untagged.
final detail = playlist.isSystem
// Refreshable (singleton) system playlists: fetch the server's
// rotation-aware order (#415) and play as-is, tagged with the
// source so the reporter advances rotation. User playlists AND
// non-singleton system kinds (songs_like_artist — no by-kind
// endpoint) play the stored order via get(), untagged.
final detail = playlist.refreshable
? await api.systemShuffle(playlist.systemVariant!)
: await api.get(playlist.id);
final refs = <TrackRef>[];
@@ -177,14 +174,13 @@ class PlaylistCard extends ConsumerWidget {
));
}
if (refs.isEmpty) return;
// System playlists already arrive in rotation-aware order from
// the server (#415) — play as-is, tagged with the variant so the
// reporter advances rotation. No client shuffle. User playlists
// play in stored order, untagged.
// Rotation-aware kinds arrive pre-ordered from the server — play
// as-is, tagged so the reporter advances rotation. No client
// shuffle. Everything else plays stored order, untagged.
await ref.read(playerActionsProvider).playTracks(
refs,
initialIndex: 0,
source: playlist.isSystem ? playlist.systemVariant : null,
source: playlist.refreshable ? playlist.systemVariant : null,
);
}
}