153 lines
5.3 KiB
Dart
153 lines
5.3 KiB
Dart
import '../subsonic/models.dart';
|
|
|
|
/// Reconstructs [Album]s and [Artist]s from a flat list of downloaded [Song]s.
|
|
/// The offline library only ever has tracks (that's all that's cached on disk),
|
|
/// so album/artist entities are synthesized on demand. Pure — no I/O.
|
|
|
|
/// Compare two strings case-insensitively, treating null as empty (sorts first).
|
|
int _byString(String? a, String? b) =>
|
|
(a ?? '').toLowerCase().compareTo((b ?? '').toLowerCase());
|
|
|
|
/// Compare where a null [a]/[b] always sorts *last*. Takes bare [Comparable] so
|
|
/// `int` (`Comparable<num>`) works for disc/track ordering.
|
|
int _nullsLast(Comparable? a, Comparable? b) {
|
|
if (a == null && b == null) return 0;
|
|
if (a == null) return 1;
|
|
if (b == null) return -1;
|
|
return a.compareTo(b);
|
|
}
|
|
|
|
/// True for a present, non-blank id/name.
|
|
bool _has(String? v) => v != null && v.trim().isNotEmpty;
|
|
|
|
/// First non-blank value in [values], or null if none.
|
|
String? _firstNonNull(Iterable<String?> values) {
|
|
for (final v in values) {
|
|
if (_has(v)) return v;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// First non-null value in [values], or null if none. For nullable ints.
|
|
int? _firstNonNullInt(Iterable<int?> values) {
|
|
for (final v in values) {
|
|
if (v != null) return v;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// Stable album key: prefer [Song.albumId], else fall back to the album NAME.
|
|
/// The synthesized [Album.id] mirrors this exactly (see [albumsFromSongs]), so
|
|
/// Phase 2 providers can look an album up by the same key it was built under.
|
|
String? _albumKey(Song s) => _has(s.albumId) ? s.albumId : s.album;
|
|
|
|
/// Stable artist key: prefer [Song.artistId], else fall back to the artist NAME.
|
|
String? _artistKey(Song s) => _has(s.artistId) ? s.artistId : s.artist;
|
|
|
|
/// Synthesize [Album]s from downloaded [Song]s, grouped by [_albumKey].
|
|
///
|
|
/// Songs with neither an albumId nor an album name are skipped — with no album
|
|
/// identity they can't form a meaningful album. The synthesized [Album.id] is
|
|
/// the albumId when present, else the album *name* itself (the same string used
|
|
/// as the grouping key), so lookups by id stay stable across rebuilds.
|
|
///
|
|
/// Returned albums are sorted by name (case-insensitive ascending) for a stable
|
|
/// default order.
|
|
List<Album> albumsFromSongs(List<Song> songs) {
|
|
// Preserve first-seen insertion order within groups; output order is imposed
|
|
// by the final sort, so the map's own ordering only needs to be deterministic.
|
|
final groups = <String, List<Song>>{};
|
|
for (final s in songs) {
|
|
final key = _albumKey(s);
|
|
if (key == null) continue; // no album identity → skip
|
|
groups.putIfAbsent(key, () => []).add(s);
|
|
}
|
|
|
|
final out = <Album>[];
|
|
groups.forEach((key, group) {
|
|
// id: albumId if any song carries one, else the name-derived key.
|
|
final albumId = _firstNonNull(group.map((s) => s.albumId));
|
|
final id = albumId ?? key;
|
|
|
|
final sorted = [...group]
|
|
..sort((a, b) {
|
|
final d = _nullsLast(a.discNumber, b.discNumber);
|
|
if (d != 0) return d;
|
|
final t = _nullsLast(a.track, b.track);
|
|
return t != 0 ? t : _byString(a.title, b.title);
|
|
});
|
|
|
|
out.add(
|
|
Album(
|
|
id: id,
|
|
name: _firstNonNull(group.map((s) => s.album)),
|
|
artist: _firstNonNull(group.map((s) => s.artist)),
|
|
artistId: _firstNonNull(group.map((s) => s.artistId)),
|
|
coverArt: _firstNonNull(group.map((s) => s.coverArt)),
|
|
year: _firstNonNullInt(group.map((s) => s.year)),
|
|
genre: _firstNonNull(group.map((s) => s.genre)),
|
|
songCount: group.length,
|
|
songs: sorted,
|
|
),
|
|
);
|
|
});
|
|
|
|
out.sort((a, b) => _byString(a.name, b.name));
|
|
return out;
|
|
}
|
|
|
|
/// Synthesize [Artist]s from downloaded [Song]s, grouped by [_artistKey].
|
|
///
|
|
/// Songs with neither an artistId nor an artist name are skipped. Each artist's
|
|
/// [Artist.albums] is built by running [albumsFromSongs] over that artist's own
|
|
/// songs, and its [Artist.id] follows the same id/name fallback as albums.
|
|
///
|
|
/// Returned artists are sorted by name (case-insensitive ascending).
|
|
List<Artist> artistsFromSongs(List<Song> songs) {
|
|
final groups = <String, List<Song>>{};
|
|
for (final s in songs) {
|
|
final key = _artistKey(s);
|
|
if (key == null) continue; // no artist identity → skip
|
|
groups.putIfAbsent(key, () => []).add(s);
|
|
}
|
|
|
|
final out = <Artist>[];
|
|
groups.forEach((key, group) {
|
|
// id: artistId if any song carries one, else the name-derived key.
|
|
final artistId = _firstNonNull(group.map((s) => s.artistId));
|
|
final id = artistId ?? key;
|
|
|
|
final albums = albumsFromSongs(group);
|
|
|
|
out.add(
|
|
Artist(
|
|
id: id,
|
|
name: _firstNonNull(group.map((s) => s.artist)),
|
|
coverArt: _firstNonNull(albums.map((a) => a.coverArt)),
|
|
albums: albums,
|
|
albumCount: albums.length,
|
|
),
|
|
);
|
|
});
|
|
|
|
out.sort((a, b) => _byString(a.name, b.name));
|
|
return out;
|
|
}
|
|
|
|
/// The synthesized album whose id == [id], or null. Keyed lookup for the album
|
|
/// detail provider.
|
|
Album? albumFromSongs(List<Song> songs, String id) {
|
|
for (final a in albumsFromSongs(songs)) {
|
|
if (a.id == id) return a;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// The synthesized artist whose id == [id], or null. Keyed lookup for the artist
|
|
/// detail provider.
|
|
Artist? artistFromSongs(List<Song> songs, String id) {
|
|
for (final a in artistsFromSongs(songs)) {
|
|
if (a.id == id) return a;
|
|
}
|
|
return null;
|
|
}
|