mobile-music/lib/subsonic/models.dart
2026-08-04 16:08:51 -04:00

365 lines
10 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.created,
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;
/// ISO8601 date the item was added to the server (`created`), or null.
/// Powers "recently added" sort / "date added" filtering.
final String? created;
final bool starred;
/// 1–5, or null if unrated.
final int? userRating;
/// Parsed form of [created], or null if absent/unparseable.
DateTime? get createdAt =>
created == null ? null : DateTime.tryParse(created!);
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']),
created: asString(j['created']),
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 (created != null) 'created': created,
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.created,
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;
/// ISO8601 date the album was added to the server (`created`), or null.
final String? created;
final bool starred;
final int? userRating;
/// Populated only by `getAlbum`.
final List<Song> songs;
/// Parsed form of [created], or null if absent/unparseable.
DateTime? get createdAt =>
created == null ? null : DateTime.tryParse(created!);
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']),
created: asString(j['created']),
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,
);
}