// 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; factory Playlist.fromJson(Map 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 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 tracks; factory PlaylistDetail.fromJson(Map j) { final tracksRaw = (j['tracks'] as List?) ?? const []; return PlaylistDetail( playlist: Playlist.fromJson(j), tracks: tracksRaw .map((e) => PlaylistTrack.fromJson((e as Map).cast())) .toList(growable: false), ); } }