Files
bvandeusen d67c0de596 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>
2026-05-15 13:21:09 -04:00

128 lines
4.5 KiB
Dart

// Mirrors internal/api/playlists.go Playlist + PlaylistDetail wire shapes.
//
// Server distinguishes user playlists (created by the caller) from
// system playlists (server-generated mixes like for_you / discover).
// system_variant is null for user playlists; "for_you" / "discover"
// otherwise. Read-only operations work the same; PATCH/POST/DELETE
// are gated on user-owned playlists.
class Playlist {
const Playlist({
required this.id,
required this.userId,
required this.name,
required this.description,
required this.isPublic,
required this.systemVariant,
required this.trackCount,
required this.coverUrl,
required this.ownerUsername,
required this.createdAt,
required this.updatedAt,
});
final String id;
final String userId;
final String name;
final String description;
final bool isPublic;
/// "for_you" / "discover" / null
final String? systemVariant;
final int trackCount;
/// Server-emitted URL to the cover; empty when no tracks yet.
final String coverUrl;
/// Display name of the owner — for showing other users' public playlists.
final String ownerUsername;
final String createdAt;
final String updatedAt;
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? ?? '',
name: j['name'] as String? ?? '',
description: j['description'] as String? ?? '',
isPublic: j['is_public'] as bool? ?? false,
systemVariant: j['system_variant'] as String?,
trackCount: (j['track_count'] as num?)?.toInt() ?? 0,
coverUrl: j['cover_url'] as String? ?? '',
ownerUsername: j['owner_username'] as String? ?? '',
createdAt: j['created_at'] as String? ?? '',
updatedAt: j['updated_at'] as String? ?? '',
);
}
/// Playlist row as returned in PlaylistDetail.tracks. Each row carries
/// the track's display fields directly so the client doesn't need a
/// separate /api/tracks/{id} fetch per row.
///
/// track_id is nullable: when the upstream track has been removed from
/// the library, the row remains in the playlist with its title/artist
/// preserved but track_id=null and stream_url=null. UI should render
/// these greyed-out and unplayable.
class PlaylistTrack {
const PlaylistTrack({
required this.position,
required this.trackId,
required this.title,
required this.albumId,
required this.albumTitle,
required this.artistId,
required this.artistName,
required this.durationSec,
required this.streamUrl,
});
final int position;
final String? trackId;
final String title;
final String? albumId;
final String albumTitle;
final String? artistId;
final String artistName;
final int durationSec;
final String? streamUrl;
bool get isAvailable => trackId != null;
factory PlaylistTrack.fromJson(Map<String, dynamic> j) => PlaylistTrack(
position: (j['position'] as num?)?.toInt() ?? 0,
trackId: j['track_id'] as String?,
title: j['title'] as String? ?? '',
albumId: j['album_id'] as String?,
albumTitle: j['album_title'] as String? ?? '',
artistId: j['artist_id'] as String?,
artistName: j['artist_name'] as String? ?? '',
durationSec: (j['duration_sec'] as num?)?.toInt() ?? 0,
streamUrl: j['stream_url'] as String?,
);
}
class PlaylistDetail {
const PlaylistDetail({required this.playlist, required this.tracks});
final Playlist playlist;
final List<PlaylistTrack> tracks;
factory PlaylistDetail.fromJson(Map<String, dynamic> j) {
final tracksRaw = (j['tracks'] as List?) ?? const [];
return PlaylistDetail(
playlist: Playlist.fromJson(j),
tracks: tracksRaw
.map((e) => PlaylistTrack.fromJson((e as Map).cast<String, dynamic>()))
.toList(growable: false),
);
}
}