feat(flutter/models): User, ArtistRef, AlbumRef, TrackRef, HomeData
Field names mirror web/src/lib/api/types.ts and the server JSON shape (internal/api/types.go's HomePayload). Hand-written DTOs per spec section 10 -- codegen revisits at ~30 models. Adopts server contract over plan-body mock: - cover_url (not cover_art_url) for artist/album refs - duration_sec (not duration_ms) for albums and tracks - stream_url, disc_number on TrackRef - sort_name/album_count on ArtistRef; sort_title/year/track_count on AlbumRef HomeData section keys match server: recently_added_albums, rediscover_albums, rediscover_artists, most_played_tracks, last_played_artists.
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
// Mirrors internal/api/types.go AlbumRef and web/src/lib/api/types.ts.
|
||||
//
|
||||
// `cover_url` (NOT cover_art_url) and `duration_sec` (NOT duration_ms) match
|
||||
// the server contract. `year` is omitempty on the server so we keep it
|
||||
// nullable. CoverURL is non-null but may be "" — UI branches on isEmpty.
|
||||
class AlbumRef {
|
||||
const AlbumRef({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.artistId,
|
||||
this.sortTitle = '',
|
||||
this.artistName = '',
|
||||
this.year,
|
||||
this.trackCount = 0,
|
||||
this.durationSec = 0,
|
||||
this.coverUrl = '',
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String title;
|
||||
final String sortTitle;
|
||||
final String artistId;
|
||||
final String artistName;
|
||||
final int? year;
|
||||
final int trackCount;
|
||||
final int durationSec;
|
||||
final String coverUrl;
|
||||
|
||||
factory AlbumRef.fromJson(Map<String, dynamic> j) => AlbumRef(
|
||||
id: j['id'] as String,
|
||||
title: j['title'] as String,
|
||||
sortTitle: j['sort_title'] as String? ?? '',
|
||||
artistId: j['artist_id'] as String,
|
||||
artistName: j['artist_name'] as String? ?? '',
|
||||
year: (j['year'] as num?)?.toInt(),
|
||||
trackCount: (j['track_count'] as num?)?.toInt() ?? 0,
|
||||
durationSec: (j['duration_sec'] as num?)?.toInt() ?? 0,
|
||||
coverUrl: j['cover_url'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Mirrors internal/api/types.go ArtistRef and web/src/lib/api/types.ts.
|
||||
//
|
||||
// `cover_url` is the server's field name (NOT cover_art_url). Server emits
|
||||
// "" when the artist has no representative album cover, so we keep it
|
||||
// non-null here and let UI code branch on isEmpty. `sort_name` and
|
||||
// `album_count` are exposed by the server contract; we accept their
|
||||
// absence defensively (older fixtures, partial mocks).
|
||||
class ArtistRef {
|
||||
const ArtistRef({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.sortName = '',
|
||||
this.albumCount = 0,
|
||||
this.coverUrl = '',
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final String sortName;
|
||||
final int albumCount;
|
||||
final String coverUrl;
|
||||
|
||||
factory ArtistRef.fromJson(Map<String, dynamic> j) => ArtistRef(
|
||||
id: j['id'] as String,
|
||||
name: j['name'] as String,
|
||||
sortName: j['sort_name'] as String? ?? '',
|
||||
albumCount: (j['album_count'] as num?)?.toInt() ?? 0,
|
||||
coverUrl: j['cover_url'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import 'album.dart';
|
||||
import 'artist.dart';
|
||||
import 'track.dart';
|
||||
|
||||
// Mirrors internal/api/types.go HomePayload and web/src/lib/api/types.ts.
|
||||
// Section keys MUST stay in sync with the server's JSON tags:
|
||||
// recently_added_albums / rediscover_albums / rediscover_artists /
|
||||
// most_played_tracks / last_played_artists.
|
||||
//
|
||||
// Server contract guarantees all five slices are non-nil at JSON encode
|
||||
// time (empty sections render as []), but we still tolerate missing keys
|
||||
// so partial fixtures and offline-degraded responses can be parsed.
|
||||
class HomeData {
|
||||
const HomeData({
|
||||
required this.recentlyAddedAlbums,
|
||||
required this.rediscoverAlbums,
|
||||
required this.rediscoverArtists,
|
||||
required this.mostPlayedTracks,
|
||||
required this.lastPlayedArtists,
|
||||
});
|
||||
|
||||
final List<AlbumRef> recentlyAddedAlbums;
|
||||
final List<AlbumRef> rediscoverAlbums;
|
||||
final List<ArtistRef> rediscoverArtists;
|
||||
final List<TrackRef> mostPlayedTracks;
|
||||
final List<ArtistRef> lastPlayedArtists;
|
||||
|
||||
factory HomeData.fromJson(Map<String, dynamic> j) => HomeData(
|
||||
recentlyAddedAlbums: _list(j, 'recently_added_albums', AlbumRef.fromJson),
|
||||
rediscoverAlbums: _list(j, 'rediscover_albums', AlbumRef.fromJson),
|
||||
rediscoverArtists: _list(j, 'rediscover_artists', ArtistRef.fromJson),
|
||||
mostPlayedTracks: _list(j, 'most_played_tracks', TrackRef.fromJson),
|
||||
lastPlayedArtists: _list(j, 'last_played_artists', ArtistRef.fromJson),
|
||||
);
|
||||
|
||||
static List<T> _list<T>(
|
||||
Map<String, dynamic> j,
|
||||
String key,
|
||||
T Function(Map<String, dynamic>) parse,
|
||||
) {
|
||||
final raw = j[key] as List? ?? const [];
|
||||
return raw
|
||||
.map((e) => parse((e as Map).cast<String, dynamic>()))
|
||||
.toList(growable: false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Mirrors internal/api/types.go TrackRef and web/src/lib/api/types.ts.
|
||||
//
|
||||
// Server uses `duration_sec` (NOT duration_ms). `track_number` and
|
||||
// `disc_number` are omitempty server-side so they're nullable here.
|
||||
// `stream_url` points at /api/tracks/{id}/stream.
|
||||
class TrackRef {
|
||||
const TrackRef({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.albumId,
|
||||
required this.artistId,
|
||||
this.albumTitle = '',
|
||||
this.artistName = '',
|
||||
this.trackNumber,
|
||||
this.discNumber,
|
||||
this.durationSec = 0,
|
||||
this.streamUrl = '',
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String title;
|
||||
final String albumId;
|
||||
final String albumTitle;
|
||||
final String artistId;
|
||||
final String artistName;
|
||||
final int? trackNumber;
|
||||
final int? discNumber;
|
||||
final int durationSec;
|
||||
final String streamUrl;
|
||||
|
||||
factory TrackRef.fromJson(Map<String, dynamic> j) => TrackRef(
|
||||
id: j['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? ?? '',
|
||||
trackNumber: (j['track_number'] as num?)?.toInt(),
|
||||
discNumber: (j['disc_number'] as num?)?.toInt(),
|
||||
durationSec: (j['duration_sec'] as num?)?.toInt() ?? 0,
|
||||
streamUrl: j['stream_url'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Mirrors internal/api/types.go UserView. The /api/* user shape — narrower
|
||||
// than dbq.User (no password hash, no api_token, no subsonic_password).
|
||||
//
|
||||
// `id` is a pgtype.UUID server-side which marshals to a plain string when
|
||||
// Valid, so we accept it as String here. `is_admin` is always present in
|
||||
// the server response but we tolerate its absence (default false) to keep
|
||||
// older fixtures and partial mocks loadable.
|
||||
class User {
|
||||
const User({
|
||||
required this.id,
|
||||
required this.username,
|
||||
required this.isAdmin,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String username;
|
||||
final bool isAdmin;
|
||||
|
||||
factory User.fromJson(Map<String, dynamic> j) => User(
|
||||
id: j['id'] as String,
|
||||
username: j['username'] as String,
|
||||
isAdmin: j['is_admin'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:minstrel/models/album.dart';
|
||||
import 'package:minstrel/models/artist.dart';
|
||||
import 'package:minstrel/models/home_data.dart';
|
||||
import 'package:minstrel/models/track.dart';
|
||||
import 'package:minstrel/models/user.dart';
|
||||
|
||||
void main() {
|
||||
group('User.fromJson', () {
|
||||
test('parses canonical UserView shape', () {
|
||||
final u = User.fromJson({
|
||||
'id': 'usr-1',
|
||||
'username': 'alice',
|
||||
'is_admin': true,
|
||||
});
|
||||
expect(u.id, 'usr-1');
|
||||
expect(u.username, 'alice');
|
||||
expect(u.isAdmin, isTrue);
|
||||
});
|
||||
|
||||
test('defaults is_admin to false when missing', () {
|
||||
final u = User.fromJson({'id': 'usr-2', 'username': 'bob'});
|
||||
expect(u.isAdmin, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('ArtistRef.fromJson', () {
|
||||
test('parses full payload using server cover_url field', () {
|
||||
final a = ArtistRef.fromJson({
|
||||
'id': 'art-1',
|
||||
'name': 'Boards of Canada',
|
||||
'sort_name': 'Boards of Canada',
|
||||
'album_count': 7,
|
||||
'cover_url': '/api/albums/al-1/cover',
|
||||
});
|
||||
expect(a.name, 'Boards of Canada');
|
||||
expect(a.sortName, 'Boards of Canada');
|
||||
expect(a.albumCount, 7);
|
||||
expect(a.coverUrl, '/api/albums/al-1/cover');
|
||||
});
|
||||
|
||||
test('tolerates missing optional fields', () {
|
||||
final a = ArtistRef.fromJson({'id': 'art-2', 'name': 'Aphex Twin'});
|
||||
expect(a.sortName, '');
|
||||
expect(a.albumCount, 0);
|
||||
expect(a.coverUrl, '');
|
||||
});
|
||||
});
|
||||
|
||||
group('AlbumRef.fromJson', () {
|
||||
test('parses full payload with year and duration_sec', () {
|
||||
final a = AlbumRef.fromJson({
|
||||
'id': 'al-1',
|
||||
'title': 'Geogaddi',
|
||||
'sort_title': 'Geogaddi',
|
||||
'artist_id': 'art-1',
|
||||
'artist_name': 'Boards of Canada',
|
||||
'year': 2002,
|
||||
'track_count': 23,
|
||||
'duration_sec': 4020,
|
||||
'cover_url': '/api/albums/al-1/cover',
|
||||
});
|
||||
expect(a.title, 'Geogaddi');
|
||||
expect(a.year, 2002);
|
||||
expect(a.trackCount, 23);
|
||||
expect(a.durationSec, 4020);
|
||||
expect(a.coverUrl, '/api/albums/al-1/cover');
|
||||
});
|
||||
|
||||
test('leaves year null when absent', () {
|
||||
final a = AlbumRef.fromJson({
|
||||
'id': 'al-2',
|
||||
'title': 'Untitled',
|
||||
'artist_id': 'art-1',
|
||||
});
|
||||
expect(a.year, isNull);
|
||||
expect(a.trackCount, 0);
|
||||
expect(a.durationSec, 0);
|
||||
});
|
||||
});
|
||||
|
||||
group('TrackRef.fromJson', () {
|
||||
test('parses full payload including stream_url', () {
|
||||
final t = TrackRef.fromJson({
|
||||
'id': 'tr-1',
|
||||
'title': 'Music Is Math',
|
||||
'album_id': 'al-1',
|
||||
'album_title': 'Geogaddi',
|
||||
'artist_id': 'art-1',
|
||||
'artist_name': 'Boards of Canada',
|
||||
'track_number': 5,
|
||||
'disc_number': 1,
|
||||
'duration_sec': 379,
|
||||
'stream_url': '/api/tracks/tr-1/stream',
|
||||
});
|
||||
expect(t.title, 'Music Is Math');
|
||||
expect(t.trackNumber, 5);
|
||||
expect(t.discNumber, 1);
|
||||
expect(t.durationSec, 379);
|
||||
expect(t.streamUrl, '/api/tracks/tr-1/stream');
|
||||
});
|
||||
|
||||
test('leaves track_number and disc_number null when omitted', () {
|
||||
final t = TrackRef.fromJson({
|
||||
'id': 'tr-2',
|
||||
'title': 'Hidden',
|
||||
'album_id': 'al-1',
|
||||
'artist_id': 'art-1',
|
||||
});
|
||||
expect(t.trackNumber, isNull);
|
||||
expect(t.discNumber, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('HomeData.fromJson', () {
|
||||
test('handles missing sections as empty lists', () {
|
||||
final h = HomeData.fromJson({});
|
||||
expect(h.recentlyAddedAlbums, isEmpty);
|
||||
expect(h.rediscoverAlbums, isEmpty);
|
||||
expect(h.rediscoverArtists, isEmpty);
|
||||
expect(h.mostPlayedTracks, isEmpty);
|
||||
expect(h.lastPlayedArtists, isEmpty);
|
||||
});
|
||||
|
||||
test('parses populated payload across all five sections', () {
|
||||
final h = HomeData.fromJson({
|
||||
'recently_added_albums': [
|
||||
{
|
||||
'id': 'al-1',
|
||||
'title': 'Geogaddi',
|
||||
'artist_id': 'art-1',
|
||||
'artist_name': 'Boards of Canada',
|
||||
'cover_url': '/api/albums/al-1/cover',
|
||||
}
|
||||
],
|
||||
'rediscover_albums': [
|
||||
{
|
||||
'id': 'al-2',
|
||||
'title': 'Music Has the Right to Children',
|
||||
'artist_id': 'art-1',
|
||||
'artist_name': 'Boards of Canada',
|
||||
}
|
||||
],
|
||||
'rediscover_artists': [
|
||||
{'id': 'art-2', 'name': 'Aphex Twin'}
|
||||
],
|
||||
'most_played_tracks': [
|
||||
{
|
||||
'id': 'tr-1',
|
||||
'title': 'Roygbiv',
|
||||
'album_id': 'al-2',
|
||||
'artist_id': 'art-1',
|
||||
'duration_sec': 150,
|
||||
}
|
||||
],
|
||||
'last_played_artists': [
|
||||
{'id': 'art-3', 'name': 'Tycho'}
|
||||
],
|
||||
});
|
||||
expect(h.recentlyAddedAlbums.single.title, 'Geogaddi');
|
||||
expect(h.recentlyAddedAlbums.single.coverUrl, '/api/albums/al-1/cover');
|
||||
expect(h.rediscoverAlbums.single.id, 'al-2');
|
||||
expect(h.rediscoverArtists.single.name, 'Aphex Twin');
|
||||
expect(h.mostPlayedTracks.single.durationSec, 150);
|
||||
expect(h.lastPlayedArtists.single.name, 'Tycho');
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user