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:
@@ -45,15 +45,13 @@ class PlaylistsApi {
|
||||
return PlaylistDetail.fromJson(r.data ?? const {});
|
||||
}
|
||||
|
||||
/// GET /api/playlists/system/{variant}/shuffle (#415 stage 2/3).
|
||||
/// GET /api/playlists/system/{kind}/shuffle (#415 / #411 R2).
|
||||
/// Same shape as get() but tracks are server-ordered rotation-aware
|
||||
/// (unplayed-this-rotation first; resets when exhausted). Model
|
||||
/// variant uses underscores; the route segment is hyphenated.
|
||||
/// Intentionally uncached — varies per play.
|
||||
/// (unplayed-this-rotation first; resets when exhausted). {kind} is
|
||||
/// the raw system_variant. Intentionally uncached — varies per play.
|
||||
Future<PlaylistDetail> systemShuffle(String variant) async {
|
||||
final seg = variant == 'for_you' ? 'for-you' : variant;
|
||||
final r = await _dio.get<Map<String, dynamic>>(
|
||||
'/api/playlists/system/$seg/shuffle',
|
||||
'/api/playlists/system/$variant/shuffle',
|
||||
);
|
||||
return PlaylistDetail.fromJson(r.data ?? const {});
|
||||
}
|
||||
@@ -67,17 +65,14 @@ class PlaylistsApi {
|
||||
);
|
||||
}
|
||||
|
||||
/// POST /api/playlists/system/{variant}/refresh. Synchronously
|
||||
/// rebuilds the caller's system playlist for the given variant
|
||||
/// ("for_you" | "discover") and returns the new playlist id, or
|
||||
/// null when the library is empty so there's nothing to build.
|
||||
///
|
||||
/// The variant uses underscores in the model (system_variant) but
|
||||
/// the route segment is hyphenated ("for-you"), so map here.
|
||||
/// POST /api/playlists/system/{kind}/refresh (#411 R2). Rebuilds
|
||||
/// the caller's system playlists and returns the named kind's new
|
||||
/// playlist id, or null when the library is empty. {kind} is the
|
||||
/// raw system_variant — the server routes generically off the
|
||||
/// kind registry, no hyphen mapping.
|
||||
Future<String?> refreshSystem(String variant) async {
|
||||
final segment = variant == 'for_you' ? 'for-you' : variant;
|
||||
final r = await _dio.post<Map<String, dynamic>>(
|
||||
'/api/playlists/system/$segment/refresh',
|
||||
'/api/playlists/system/$variant/refresh',
|
||||
data: const <String, dynamic>{},
|
||||
);
|
||||
return (r.data ?? const {})['playlist_id'] as String?;
|
||||
|
||||
@@ -38,6 +38,16 @@ class Playlist {
|
||||
|
||||
bool get isSystem => systemVariant != null;
|
||||
|
||||
/// Whether this playlist supports the generic by-kind refresh/
|
||||
/// shuffle endpoints (#411 R2) — i.e. a singleton system kind.
|
||||
/// The server exposes a `refreshable` flag for JSON-sourced
|
||||
/// playlists, but the list tiles are drift-cache-sourced (no
|
||||
/// migration just for this), so derive it: every system kind is a
|
||||
/// singleton except songs_like_artist (multi-per-user). This rule
|
||||
/// holds for For-You/Discover and all planned discovery mixes; if
|
||||
/// a future non-singleton kind appears, extend the exclusion.
|
||||
bool get refreshable => isSystem && systemVariant != 'songs_like_artist';
|
||||
|
||||
factory Playlist.fromJson(Map<String, dynamic> j) => Playlist(
|
||||
id: j['id'] as String,
|
||||
userId: j['user_id'] as String? ?? '',
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user