This commit is contained in:
Forrest 2026-07-29 14:14:18 -04:00
commit d205277cdd
182 changed files with 22978 additions and 0 deletions

331
lib/subsonic/models.dart Normal file
View file

@ -0,0 +1,331 @@
import 'json_helpers.dart';
/// Subsonic domain models, ported from `ratune-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
/// `ratune-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,
});
final String id;
final String name;
final int? songCount;
final int? duration;
final String? owner;
final bool? public;
final String? coverArt;
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']),
);
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,
};
}
/// 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.songs = const [],
});
final String id;
final String name;
final int? songCount;
final int? duration;
final String? coverArt;
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']),
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,
'entry': songs.map((s) => s.toJson()).toList(),
};
Playlist toSummary() => Playlist(
id: id,
name: name,
songCount: songCount ?? songs.length,
duration: duration,
coverArt: coverArt,
);
}