345 lines
9.5 KiB
Dart
345 lines
9.5 KiB
Dart
import 'json_helpers.dart';
|
||
|
||
/// Subsonic domain models, ported from `timbre-subsonic/src/models.rs`.
|
||
/// Nearly every field is nullable — servers omit fields freely. All parsing
|
||
/// goes through the defensive helpers in `json_helpers.dart`.
|
||
|
||
class Song {
|
||
Song({
|
||
required this.id,
|
||
this.title,
|
||
this.album,
|
||
this.artist,
|
||
this.albumId,
|
||
this.artistId,
|
||
this.track,
|
||
this.discNumber,
|
||
this.year,
|
||
this.genre,
|
||
this.coverArt,
|
||
this.duration,
|
||
this.bitRate,
|
||
this.contentType,
|
||
this.suffix,
|
||
this.size,
|
||
this.path,
|
||
this.starred = false,
|
||
this.userRating,
|
||
});
|
||
|
||
final String id;
|
||
final String? title;
|
||
final String? album;
|
||
final String? artist;
|
||
final String? albumId;
|
||
final String? artistId;
|
||
final int? track;
|
||
final int? discNumber;
|
||
final int? year;
|
||
final String? genre;
|
||
final String? coverArt;
|
||
|
||
/// Track length in seconds.
|
||
final int? duration;
|
||
final int? bitRate;
|
||
final String? contentType;
|
||
final String? suffix;
|
||
final int? size;
|
||
final String? path;
|
||
final bool starred;
|
||
|
||
/// 1–5, or null if unrated.
|
||
final int? userRating;
|
||
|
||
factory Song.fromJson(Map<String, dynamic> j) => Song(
|
||
id: asString(j['id']) ?? '',
|
||
title: asString(j['title']),
|
||
album: asString(j['album']),
|
||
artist: asString(j['artist']),
|
||
albumId: asString(j['albumId']),
|
||
artistId: asString(j['artistId']),
|
||
track: asInt(j['track']),
|
||
discNumber: asInt(j['discNumber']),
|
||
year: asInt(j['year']),
|
||
genre: asString(j['genre']),
|
||
coverArt: asString(j['coverArt']),
|
||
duration: asInt(j['duration']),
|
||
bitRate: asInt(j['bitRate']),
|
||
contentType: asString(j['contentType']),
|
||
suffix: asString(j['suffix']),
|
||
size: asInt(j['size']),
|
||
path: asString(j['path']),
|
||
starred: j['starred'] != null,
|
||
userRating: asInt(j['userRating']),
|
||
);
|
||
|
||
/// Round-trips through [Song.fromJson]. Backs the persisted library index
|
||
/// (`library_index.dart`); only emits set fields to keep the cache compact.
|
||
Map<String, dynamic> toJson() => {
|
||
'id': id,
|
||
if (title != null) 'title': title,
|
||
if (album != null) 'album': album,
|
||
if (artist != null) 'artist': artist,
|
||
if (albumId != null) 'albumId': albumId,
|
||
if (artistId != null) 'artistId': artistId,
|
||
if (track != null) 'track': track,
|
||
if (discNumber != null) 'discNumber': discNumber,
|
||
if (year != null) 'year': year,
|
||
if (genre != null) 'genre': genre,
|
||
if (coverArt != null) 'coverArt': coverArt,
|
||
if (duration != null) 'duration': duration,
|
||
if (bitRate != null) 'bitRate': bitRate,
|
||
if (contentType != null) 'contentType': contentType,
|
||
if (suffix != null) 'suffix': suffix,
|
||
if (size != null) 'size': size,
|
||
if (path != null) 'path': path,
|
||
if (starred) 'starred': true,
|
||
if (userRating != null) 'userRating': userRating,
|
||
};
|
||
}
|
||
|
||
class Album {
|
||
Album({
|
||
required this.id,
|
||
this.name,
|
||
this.artist,
|
||
this.artistId,
|
||
this.coverArt,
|
||
this.songCount,
|
||
this.duration,
|
||
this.year,
|
||
this.genre,
|
||
this.starred = false,
|
||
this.userRating,
|
||
this.songs = const [],
|
||
});
|
||
|
||
final String id;
|
||
final String? name;
|
||
final String? artist;
|
||
final String? artistId;
|
||
final String? coverArt;
|
||
final int? songCount;
|
||
final int? duration;
|
||
final int? year;
|
||
final String? genre;
|
||
final bool starred;
|
||
final int? userRating;
|
||
|
||
/// Populated only by `getAlbum`.
|
||
final List<Song> songs;
|
||
|
||
factory Album.fromJson(Map<String, dynamic> j) => Album(
|
||
id: asString(j['id']) ?? '',
|
||
name: asString(j['name']) ?? asString(j['album']),
|
||
artist: asString(j['artist']),
|
||
artistId: asString(j['artistId']),
|
||
coverArt: asString(j['coverArt']),
|
||
songCount: asInt(j['songCount']),
|
||
duration: asInt(j['duration']),
|
||
year: asInt(j['year']),
|
||
genre: asString(j['genre']),
|
||
starred: j['starred'] != null,
|
||
userRating: asInt(j['userRating']),
|
||
songs: oneOrMany(j['song'], Song.fromJson),
|
||
);
|
||
}
|
||
|
||
class Artist {
|
||
Artist({
|
||
required this.id,
|
||
this.name,
|
||
this.albumCount,
|
||
this.coverArt,
|
||
this.starred = false,
|
||
this.userRating,
|
||
this.albums = const [],
|
||
});
|
||
|
||
final String id;
|
||
final String? name;
|
||
final int? albumCount;
|
||
final String? coverArt;
|
||
final bool starred;
|
||
final int? userRating;
|
||
|
||
/// Populated only by `getArtist`.
|
||
final List<Album> albums;
|
||
|
||
factory Artist.fromJson(Map<String, dynamic> j) => Artist(
|
||
id: asString(j['id']) ?? '',
|
||
name: asString(j['name']),
|
||
albumCount: asInt(j['albumCount']),
|
||
coverArt: asString(j['coverArt']),
|
||
starred: j['starred'] != null,
|
||
userRating: asInt(j['userRating']),
|
||
albums: oneOrMany(j['album'], Album.fromJson),
|
||
);
|
||
}
|
||
|
||
/// A lettered bucket of artists from `getArtists` (`<index name="A">`).
|
||
class ArtistIndex {
|
||
ArtistIndex({required this.name, required this.artists});
|
||
|
||
final String name;
|
||
final List<Artist> artists;
|
||
|
||
factory ArtistIndex.fromJson(Map<String, dynamic> j) => ArtistIndex(
|
||
name: asString(j['name']) ?? '',
|
||
artists: oneOrMany(j['artist'], Artist.fromJson),
|
||
);
|
||
}
|
||
|
||
/// Flattened result of `getArtists`.
|
||
class ArtistsResult {
|
||
ArtistsResult({required this.indexes});
|
||
|
||
final List<ArtistIndex> indexes;
|
||
|
||
List<Artist> get all => [for (final i in indexes) ...i.artists];
|
||
|
||
factory ArtistsResult.fromJson(Map<String, dynamic> j) => ArtistsResult(
|
||
indexes: oneOrMany(j['index'], ArtistIndex.fromJson),
|
||
);
|
||
}
|
||
|
||
/// Result of `getStarred2` — the user's favorites.
|
||
class Starred2 {
|
||
Starred2({
|
||
required this.artists,
|
||
required this.albums,
|
||
required this.songs,
|
||
});
|
||
|
||
final List<Artist> artists;
|
||
final List<Album> albums;
|
||
final List<Song> songs;
|
||
|
||
factory Starred2.fromJson(Map<String, dynamic> j) => Starred2(
|
||
artists: oneOrMany(j['artist'], Artist.fromJson),
|
||
albums: oneOrMany(j['album'], Album.fromJson),
|
||
songs: oneOrMany(j['song'], Song.fromJson),
|
||
);
|
||
}
|
||
|
||
/// Result of `search3`.
|
||
class SearchResult3 {
|
||
SearchResult3({
|
||
required this.artists,
|
||
required this.albums,
|
||
required this.songs,
|
||
});
|
||
|
||
final List<Artist> artists;
|
||
final List<Album> albums;
|
||
final List<Song> songs;
|
||
|
||
factory SearchResult3.fromJson(Map<String, dynamic> j) => SearchResult3(
|
||
artists: oneOrMany(j['artist'], Artist.fromJson),
|
||
albums: oneOrMany(j['album'], Album.fromJson),
|
||
songs: oneOrMany(j['song'], Song.fromJson),
|
||
);
|
||
}
|
||
|
||
/// A playlist summary from `getPlaylists` (no tracks), ported from
|
||
/// `timbre-subsonic/src/models.rs`. [toJson] backs the offline playlist mirror
|
||
/// (`playlists/playlists.dart`).
|
||
class Playlist {
|
||
Playlist({
|
||
required this.id,
|
||
required this.name,
|
||
this.songCount,
|
||
this.duration,
|
||
this.owner,
|
||
this.public,
|
||
this.coverArt,
|
||
this.comment,
|
||
});
|
||
|
||
final String id;
|
||
final String name;
|
||
final int? songCount;
|
||
final int? duration;
|
||
final String? owner;
|
||
final bool? public;
|
||
final String? coverArt;
|
||
|
||
/// Free-text playlist comment. Timbre stores a marker here to flag a playlist
|
||
/// as a "tag" (see `playlists.dart`'s `kTagMarker`); otherwise usually null.
|
||
final String? comment;
|
||
|
||
factory Playlist.fromJson(Map<String, dynamic> j) => Playlist(
|
||
id: asString(j['id']) ?? '',
|
||
name: asString(j['name']) ?? 'Untitled',
|
||
songCount: asInt(j['songCount']),
|
||
duration: asInt(j['duration']),
|
||
owner: asString(j['owner']),
|
||
public: j['public'] is bool ? j['public'] as bool : null,
|
||
coverArt: asString(j['coverArt']),
|
||
comment: asString(j['comment']),
|
||
);
|
||
|
||
Map<String, dynamic> toJson() => {
|
||
'id': id,
|
||
'name': name,
|
||
if (songCount != null) 'songCount': songCount,
|
||
if (duration != null) 'duration': duration,
|
||
if (owner != null) 'owner': owner,
|
||
if (public != null) 'public': public,
|
||
if (coverArt != null) 'coverArt': coverArt,
|
||
if (comment != null) 'comment': comment,
|
||
};
|
||
}
|
||
|
||
/// A playlist with its full track list from `getPlaylist`. The Subsonic API
|
||
/// nests the tracks under the key `entry` (not `song`).
|
||
class PlaylistDetail {
|
||
PlaylistDetail({
|
||
required this.id,
|
||
required this.name,
|
||
this.songCount,
|
||
this.duration,
|
||
this.coverArt,
|
||
this.comment,
|
||
this.songs = const [],
|
||
});
|
||
|
||
final String id;
|
||
final String name;
|
||
final int? songCount;
|
||
final int? duration;
|
||
final String? coverArt;
|
||
|
||
/// See [Playlist.comment] — carries the Timbre tag marker when present.
|
||
final String? comment;
|
||
final List<Song> songs;
|
||
|
||
factory PlaylistDetail.fromJson(Map<String, dynamic> j) => PlaylistDetail(
|
||
id: asString(j['id']) ?? '',
|
||
name: asString(j['name']) ?? 'Untitled',
|
||
songCount: asInt(j['songCount']),
|
||
duration: asInt(j['duration']),
|
||
coverArt: asString(j['coverArt']),
|
||
comment: asString(j['comment']),
|
||
songs: oneOrMany(j['entry'], Song.fromJson),
|
||
);
|
||
|
||
Map<String, dynamic> toJson() => {
|
||
'id': id,
|
||
'name': name,
|
||
if (songCount != null) 'songCount': songCount,
|
||
if (duration != null) 'duration': duration,
|
||
if (coverArt != null) 'coverArt': coverArt,
|
||
if (comment != null) 'comment': comment,
|
||
'entry': songs.map((s) => s.toJson()).toList(),
|
||
};
|
||
|
||
Playlist toSummary() => Playlist(
|
||
id: id,
|
||
name: name,
|
||
songCount: songCount ?? songs.length,
|
||
duration: duration,
|
||
coverArt: coverArt,
|
||
comment: comment,
|
||
);
|
||
}
|