stop streaming iterables

add gonic to test servers setup
gather artist image URLs on allArtists to remove weird Future<Uri> interface for artist images
move source options around
This commit is contained in:
austinried 2025-11-02 11:56:17 +09:00
parent 3408a3988e
commit c900c9750a
7 changed files with 149 additions and 95 deletions

View File

@ -8,15 +8,34 @@ services:
navidrome: navidrome:
image: deluan/navidrome:latest image: deluan/navidrome:latest
ports: ports:
- "4533:4533" - 4533:4533
restart: unless-stopped restart: unless-stopped
environment: environment:
ND_LOGLEVEL: debug ND_LOGLEVEL: debug
volumes: volumes:
- navidrome-data:/data - navidrome-data:/data
- music:/music:ro - music:/music:ro
gonic:
image: sentriz/gonic:latest
environment:
- TZ
- GONIC_SCAN_AT_START_ENABLED=true
- GONIC_SCAN_WATCHER_ENABLED=true
ports:
- 4747:80
volumes:
- gonic-data:/data
- music:/music:ro
- gonic-podcasts:/podcasts
- gonic-playlists:/playlists
- gonic-cache:/cache
volumes: volumes:
deno-dir: deno-dir:
music: music:
navidrome-data: navidrome-data:
gonic-data:
gonic-podcasts:
gonic-playlists:
gonic-cache:

View File

@ -19,6 +19,8 @@ abstract class SourceItem with _$SourceItem {
required String id, required String id,
required String name, required String name,
DateTime? starred, DateTime? starred,
Uri? smallImage,
Uri? largeImage,
}) = SourceArtist; }) = SourceArtist;
@With<Starred>() @With<Starred>()

View File

@ -159,10 +159,10 @@ return song(_that);case _:
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>({TResult Function( String id, String name, DateTime? starred)? artist,TResult Function( String id, String? artistId, String name, String? albumArtist, DateTime created, String? coverArt, int? year, DateTime? starred, String? genre, int? frequentRank, int? recentRank)? album,TResult Function( String id, String name, String? comment, DateTime created, DateTime changed, String? coverArt, String? owner, bool? public)? playlist,TResult Function( String id, String? albumId, String? artistId, String title, String? artist, String? album, Duration? duration, int? track, int? disc, DateTime? starred, String? genre, String? coverArt)? song,required TResult orElse(),}) {final _that = this; @optionalTypeArgs TResult maybeWhen<TResult extends Object?>({TResult Function( String id, String name, DateTime? starred, Uri? smallImage, Uri? largeImage)? artist,TResult Function( String id, String? artistId, String name, String? albumArtist, DateTime created, String? coverArt, int? year, DateTime? starred, String? genre, int? frequentRank, int? recentRank)? album,TResult Function( String id, String name, String? comment, DateTime created, DateTime changed, String? coverArt, String? owner, bool? public)? playlist,TResult Function( String id, String? albumId, String? artistId, String title, String? artist, String? album, Duration? duration, int? track, int? disc, DateTime? starred, String? genre, String? coverArt)? song,required TResult orElse(),}) {final _that = this;
switch (_that) { switch (_that) {
case SourceArtist() when artist != null: case SourceArtist() when artist != null:
return artist(_that.id,_that.name,_that.starred);case SourceAlbum() when album != null: return artist(_that.id,_that.name,_that.starred,_that.smallImage,_that.largeImage);case SourceAlbum() when album != null:
return album(_that.id,_that.artistId,_that.name,_that.albumArtist,_that.created,_that.coverArt,_that.year,_that.starred,_that.genre,_that.frequentRank,_that.recentRank);case SourcePlaylist() when playlist != null: return album(_that.id,_that.artistId,_that.name,_that.albumArtist,_that.created,_that.coverArt,_that.year,_that.starred,_that.genre,_that.frequentRank,_that.recentRank);case SourcePlaylist() when playlist != null:
return playlist(_that.id,_that.name,_that.comment,_that.created,_that.changed,_that.coverArt,_that.owner,_that.public);case SourceSong() when song != null: return playlist(_that.id,_that.name,_that.comment,_that.created,_that.changed,_that.coverArt,_that.owner,_that.public);case SourceSong() when song != null:
return song(_that.id,_that.albumId,_that.artistId,_that.title,_that.artist,_that.album,_that.duration,_that.track,_that.disc,_that.starred,_that.genre,_that.coverArt);case _: return song(_that.id,_that.albumId,_that.artistId,_that.title,_that.artist,_that.album,_that.duration,_that.track,_that.disc,_that.starred,_that.genre,_that.coverArt);case _:
@ -183,10 +183,10 @@ return song(_that.id,_that.albumId,_that.artistId,_that.title,_that.artist,_that
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult when<TResult extends Object?>({required TResult Function( String id, String name, DateTime? starred) artist,required TResult Function( String id, String? artistId, String name, String? albumArtist, DateTime created, String? coverArt, int? year, DateTime? starred, String? genre, int? frequentRank, int? recentRank) album,required TResult Function( String id, String name, String? comment, DateTime created, DateTime changed, String? coverArt, String? owner, bool? public) playlist,required TResult Function( String id, String? albumId, String? artistId, String title, String? artist, String? album, Duration? duration, int? track, int? disc, DateTime? starred, String? genre, String? coverArt) song,}) {final _that = this; @optionalTypeArgs TResult when<TResult extends Object?>({required TResult Function( String id, String name, DateTime? starred, Uri? smallImage, Uri? largeImage) artist,required TResult Function( String id, String? artistId, String name, String? albumArtist, DateTime created, String? coverArt, int? year, DateTime? starred, String? genre, int? frequentRank, int? recentRank) album,required TResult Function( String id, String name, String? comment, DateTime created, DateTime changed, String? coverArt, String? owner, bool? public) playlist,required TResult Function( String id, String? albumId, String? artistId, String title, String? artist, String? album, Duration? duration, int? track, int? disc, DateTime? starred, String? genre, String? coverArt) song,}) {final _that = this;
switch (_that) { switch (_that) {
case SourceArtist(): case SourceArtist():
return artist(_that.id,_that.name,_that.starred);case SourceAlbum(): return artist(_that.id,_that.name,_that.starred,_that.smallImage,_that.largeImage);case SourceAlbum():
return album(_that.id,_that.artistId,_that.name,_that.albumArtist,_that.created,_that.coverArt,_that.year,_that.starred,_that.genre,_that.frequentRank,_that.recentRank);case SourcePlaylist(): return album(_that.id,_that.artistId,_that.name,_that.albumArtist,_that.created,_that.coverArt,_that.year,_that.starred,_that.genre,_that.frequentRank,_that.recentRank);case SourcePlaylist():
return playlist(_that.id,_that.name,_that.comment,_that.created,_that.changed,_that.coverArt,_that.owner,_that.public);case SourceSong(): return playlist(_that.id,_that.name,_that.comment,_that.created,_that.changed,_that.coverArt,_that.owner,_that.public);case SourceSong():
return song(_that.id,_that.albumId,_that.artistId,_that.title,_that.artist,_that.album,_that.duration,_that.track,_that.disc,_that.starred,_that.genre,_that.coverArt);case _: return song(_that.id,_that.albumId,_that.artistId,_that.title,_that.artist,_that.album,_that.duration,_that.track,_that.disc,_that.starred,_that.genre,_that.coverArt);case _:
@ -206,10 +206,10 @@ return song(_that.id,_that.albumId,_that.artistId,_that.title,_that.artist,_that
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>({TResult? Function( String id, String name, DateTime? starred)? artist,TResult? Function( String id, String? artistId, String name, String? albumArtist, DateTime created, String? coverArt, int? year, DateTime? starred, String? genre, int? frequentRank, int? recentRank)? album,TResult? Function( String id, String name, String? comment, DateTime created, DateTime changed, String? coverArt, String? owner, bool? public)? playlist,TResult? Function( String id, String? albumId, String? artistId, String title, String? artist, String? album, Duration? duration, int? track, int? disc, DateTime? starred, String? genre, String? coverArt)? song,}) {final _that = this; @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>({TResult? Function( String id, String name, DateTime? starred, Uri? smallImage, Uri? largeImage)? artist,TResult? Function( String id, String? artistId, String name, String? albumArtist, DateTime created, String? coverArt, int? year, DateTime? starred, String? genre, int? frequentRank, int? recentRank)? album,TResult? Function( String id, String name, String? comment, DateTime created, DateTime changed, String? coverArt, String? owner, bool? public)? playlist,TResult? Function( String id, String? albumId, String? artistId, String title, String? artist, String? album, Duration? duration, int? track, int? disc, DateTime? starred, String? genre, String? coverArt)? song,}) {final _that = this;
switch (_that) { switch (_that) {
case SourceArtist() when artist != null: case SourceArtist() when artist != null:
return artist(_that.id,_that.name,_that.starred);case SourceAlbum() when album != null: return artist(_that.id,_that.name,_that.starred,_that.smallImage,_that.largeImage);case SourceAlbum() when album != null:
return album(_that.id,_that.artistId,_that.name,_that.albumArtist,_that.created,_that.coverArt,_that.year,_that.starred,_that.genre,_that.frequentRank,_that.recentRank);case SourcePlaylist() when playlist != null: return album(_that.id,_that.artistId,_that.name,_that.albumArtist,_that.created,_that.coverArt,_that.year,_that.starred,_that.genre,_that.frequentRank,_that.recentRank);case SourcePlaylist() when playlist != null:
return playlist(_that.id,_that.name,_that.comment,_that.created,_that.changed,_that.coverArt,_that.owner,_that.public);case SourceSong() when song != null: return playlist(_that.id,_that.name,_that.comment,_that.created,_that.changed,_that.coverArt,_that.owner,_that.public);case SourceSong() when song != null:
return song(_that.id,_that.albumId,_that.artistId,_that.title,_that.artist,_that.album,_that.duration,_that.track,_that.disc,_that.starred,_that.genre,_that.coverArt);case _: return song(_that.id,_that.albumId,_that.artistId,_that.title,_that.artist,_that.album,_that.duration,_that.track,_that.disc,_that.starred,_that.genre,_that.coverArt);case _:
@ -224,12 +224,14 @@ return song(_that.id,_that.albumId,_that.artistId,_that.title,_that.artist,_that
class SourceArtist with Starred implements SourceItem { class SourceArtist with Starred implements SourceItem {
const SourceArtist({required this.id, required this.name, this.starred}); const SourceArtist({required this.id, required this.name, this.starred, this.smallImage, this.largeImage});
@override final String id; @override final String id;
final String name; final String name;
final DateTime? starred; final DateTime? starred;
final Uri? smallImage;
final Uri? largeImage;
/// Create a copy of SourceItem /// Create a copy of SourceItem
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@ -241,16 +243,16 @@ $SourceArtistCopyWith<SourceArtist> get copyWith => _$SourceArtistCopyWithImpl<S
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is SourceArtist&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.starred, starred) || other.starred == starred)); return identical(this, other) || (other.runtimeType == runtimeType&&other is SourceArtist&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.starred, starred) || other.starred == starred)&&(identical(other.smallImage, smallImage) || other.smallImage == smallImage)&&(identical(other.largeImage, largeImage) || other.largeImage == largeImage));
} }
@override @override
int get hashCode => Object.hash(runtimeType,id,name,starred); int get hashCode => Object.hash(runtimeType,id,name,starred,smallImage,largeImage);
@override @override
String toString() { String toString() {
return 'SourceItem.artist(id: $id, name: $name, starred: $starred)'; return 'SourceItem.artist(id: $id, name: $name, starred: $starred, smallImage: $smallImage, largeImage: $largeImage)';
} }
@ -261,7 +263,7 @@ abstract mixin class $SourceArtistCopyWith<$Res> implements $SourceItemCopyWith<
factory $SourceArtistCopyWith(SourceArtist value, $Res Function(SourceArtist) _then) = _$SourceArtistCopyWithImpl; factory $SourceArtistCopyWith(SourceArtist value, $Res Function(SourceArtist) _then) = _$SourceArtistCopyWithImpl;
@override @useResult @override @useResult
$Res call({ $Res call({
String id, String name, DateTime? starred String id, String name, DateTime? starred, Uri? smallImage, Uri? largeImage
}); });
@ -278,12 +280,14 @@ class _$SourceArtistCopyWithImpl<$Res>
/// Create a copy of SourceItem /// Create a copy of SourceItem
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? name = null,Object? starred = freezed,}) { @override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? name = null,Object? starred = freezed,Object? smallImage = freezed,Object? largeImage = freezed,}) {
return _then(SourceArtist( return _then(SourceArtist(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
as String,starred: freezed == starred ? _self.starred : starred // ignore: cast_nullable_to_non_nullable as String,starred: freezed == starred ? _self.starred : starred // ignore: cast_nullable_to_non_nullable
as DateTime?, as DateTime?,smallImage: freezed == smallImage ? _self.smallImage : smallImage // ignore: cast_nullable_to_non_nullable
as Uri?,largeImage: freezed == largeImage ? _self.largeImage : largeImage // ignore: cast_nullable_to_non_nullable
as Uri?,
)); ));
} }

View File

@ -3,15 +3,14 @@ import 'models.dart';
abstract class MusicSource { abstract class MusicSource {
Future<void> ping(); Future<void> ping();
Stream<Iterable<SourceAlbum>> allAlbums(); Stream<SourceAlbum> allAlbums();
Stream<Iterable<SourceArtist>> allArtists(); Stream<SourceArtist> allArtists();
Stream<Iterable<SourcePlaylist>> allPlaylists(); Stream<SourcePlaylist> allPlaylists();
Stream<Iterable<SourceSong>> allSongs(); Stream<SourceSong> allSongs();
Stream<Iterable<SourcePlaylistSong>> allPlaylistSongs(); Stream<SourcePlaylistSong> allPlaylistSongs();
Uri streamUri(String songId); Uri streamUri(String songId);
Uri downloadUri(String songId); Uri downloadUri(String songId);
Uri coverArtUri(String coverArtId, {bool thumbnail = true}); Uri coverArtUri(String coverArtId, {bool thumbnail = true});
Future<Uri?> artistArtUri(String artistId, {bool thumbnail = true});
} }

View File

@ -2,10 +2,12 @@ import 'package:xml/xml.dart';
import '../models.dart'; import '../models.dart';
SourceArtist mapArtist(XmlElement e) => SourceArtist( SourceArtist mapArtist(XmlElement e, XmlElement? info) => SourceArtist(
id: e.getAttribute('id')!, id: e.getAttribute('id')!,
name: e.getAttribute('name')!, name: e.getAttribute('name')!,
starred: DateTime.tryParse(e.getAttribute('starred').toString()), starred: DateTime.tryParse(e.getAttribute('starred').toString()),
smallImage: Uri.tryParse(info?.getElement('smallImageUrl')?.innerText ?? ''),
largeImage: Uri.tryParse(info?.getElement('largeImageUrl')?.innerText ?? ''),
); );
SourceAlbum mapAlbum( SourceAlbum mapAlbum(

View File

@ -9,17 +9,17 @@ import 'client.dart';
import 'mapping.dart'; import 'mapping.dart';
class SubsonicSource implements MusicSource { class SubsonicSource implements MusicSource {
SubsonicSource({ SubsonicSource(
required this.client, this.client, {
required this.maxBitrate, this.maxConnections = 10,
this.streamFormat, this.connectionPoolTimeout = const Duration(seconds: 60),
}); });
final SubsonicClient client; final SubsonicClient client;
final int maxBitrate; final int maxConnections;
final String? streamFormat; final Duration connectionPoolTimeout;
final _pool = Pool(10, timeout: const Duration(seconds: 60)); late final _pool = Pool(maxConnections, timeout: connectionPoolTimeout);
bool? _featureEmptyQuerySearch; bool? _featureEmptyQuerySearch;
Future<bool> get supportsFastSongSync async { Future<bool> get supportsFastSongSync async {
@ -41,23 +41,31 @@ class SubsonicSource implements MusicSource {
} }
@override @override
Stream<Iterable<SourceArtist>> allArtists() async* { Stream<SourceArtist> allArtists() async* {
final res = await client.get('getArtists'); final getArtistsRes = await _pool.withResource(
() => client.get('getArtists'),
);
for (var artists in res.xml.findAllElements('artist').slices(200)) { yield* _pool.forEach(getArtistsRes.xml.findAllElements('artist'), (
yield artists.map(mapArtist); artist,
} ) async {
final res = await client.get('getArtistInfo2', {
'id': artist.getAttribute('id')!,
});
return mapArtist(artist, res.xml.getElement('artistInfo2'));
});
} }
@override @override
Stream<Iterable<SourceAlbum>> allAlbums() async* { Stream<SourceAlbum> allAlbums() async* {
final extras = await Future.wait([ final extras = await Future.wait([
_albumList( _albumList(
'frequent', 'frequent',
).flatten().map((element) => element.getAttribute('id')!).toList(), ).flatten().map((e) => e.getAttribute('id')!).toList(),
_albumList( _albumList(
'recent', 'recent',
).flatten().map((element) => element.getAttribute('id')!).toList(), ).flatten().map((e) => e.getAttribute('id')!).toList(),
]); ]);
final frequentlyPlayed = { final frequentlyPlayed = {
@ -67,65 +75,73 @@ class SubsonicSource implements MusicSource {
for (var i = 0; i < extras[1].length; i++) extras[1][i]: i, for (var i = 0; i < extras[1].length; i++) extras[1][i]: i,
}; };
await for (var albums in _albumList('newest')) { await for (final albums in _albumList('newest')) {
yield albums.map( yield* Stream.fromIterable(
(e) => mapAlbum( albums.map(
e, (album) => mapAlbum(
frequentRank: frequentlyPlayed[e.getAttribute('id')!], album,
recentRank: recentlyPlayed[e.getAttribute('id')!], frequentRank: frequentlyPlayed[album.getAttribute('id')!],
recentRank: recentlyPlayed[album.getAttribute('id')!],
),
), ),
); );
} }
} }
@override @override
Stream<Iterable<SourcePlaylist>> allPlaylists() async* { Stream<SourcePlaylist> allPlaylists() async* {
final res = await client.get('getPlaylists'); final res = await _pool.withResource(() => client.get('getPlaylists'));
for (var playlists in res.xml.findAllElements('playlist').slices(200)) { yield* Stream.fromIterable(
yield playlists.map(mapPlaylist); res.xml.findAllElements('playlist').map(mapPlaylist),
} );
} }
@override @override
Stream<Iterable<SourcePlaylistSong>> allPlaylistSongs() async* { Stream<SourcePlaylistSong> allPlaylistSongs() async* {
final allPlaylists = await client.get('getPlaylists'); final allPlaylists = await _pool.withResource(
() => client.get('getPlaylists'),
);
yield* _pool.forEach(allPlaylists.xml.findAllElements('playlist'), ( yield* _pool
playlist, .forEach(allPlaylists.xml.findAllElements('playlist'), (
) async { playlist,
final id = playlist.getAttribute('id')!; ) async {
final res = await client.get('getPlaylist', {'id': id}); final id = playlist.getAttribute('id')!;
final res = await client.get('getPlaylist', {'id': id});
return res.xml.findAllElements('entry').mapIndexed(mapPlaylistSong); return res.xml.findAllElements('entry').mapIndexed(mapPlaylistSong);
}); })
.expand((a) => a);
} }
@override @override
Stream<Iterable<SourceSong>> allSongs() async* { Stream<SourceSong> allSongs() async* {
if (await supportsFastSongSync) { if (await supportsFastSongSync) {
await for (var songs in _songSearch()) { await for (var songs in _songSearch()) {
yield songs.map(mapSong); yield* Stream.fromIterable(songs.map(mapSong));
} }
} else { } else {
await for (var albumsList in _albumList('alphabeticalByName')) { await for (var albumsList in _albumList('alphabeticalByName')) {
yield* _pool.forEach(albumsList, (album) async { yield* _pool
final albums = await client.get('getAlbum', { .forEach(albumsList, (album) async {
'id': album.getAttribute('id')!, final albums = await client.get('getAlbum', {
}); 'id': album.getAttribute('id')!,
return albums.xml.findAllElements('song').map(mapSong); });
}); return albums.xml.findAllElements('song').map(mapSong);
})
.expand((a) => a);
} }
} }
} }
@override @override
Uri streamUri(String songId) { Uri streamUri(String songId, {int? maxBitrate, String? format}) {
return client.uri('stream', { return client.uri('stream', {
'id': songId, 'id': songId,
'estimateContentLength': true.toString(), 'estimateContentLength': true.toString(),
'maxBitRate': maxBitrate.toString(), 'maxBitRate': maxBitrate.toString(),
'format': streamFormat?.toString(), 'format': format.toString(),
}); });
} }
@ -143,28 +159,18 @@ class SubsonicSource implements MusicSource {
return client.uri('getCoverArt', opts); return client.uri('getCoverArt', opts);
} }
@override
Future<Uri?> artistArtUri(String artistId, {bool thumbnail = true}) async {
final res = await client.get('getArtistInfo2', {'id': artistId});
return Uri.tryParse(
res.xml
.getElement('artistInfo2')
?.getElement(thumbnail ? 'smallImageUrl' : 'largeImageUrl')
?.text ??
'',
);
}
Stream<Iterable<XmlElement>> _albumList(String type) async* { Stream<Iterable<XmlElement>> _albumList(String type) async* {
const size = 500; const size = 500;
var offset = 0; var offset = 0;
while (true) { while (true) {
final res = await client.get('getAlbumList2', { final res = await _pool.withResource(
'type': type, () => client.get('getAlbumList2', {
'size': size.toString(), 'type': type,
'offset': offset.toString(), 'size': size.toString(),
}); 'offset': offset.toString(),
}),
);
final albums = res.xml.findAllElements('album'); final albums = res.xml.findAllElements('album');
offset += albums.length; offset += albums.length;
@ -182,13 +188,15 @@ class SubsonicSource implements MusicSource {
var offset = 0; var offset = 0;
while (true) { while (true) {
final res = await client.get('search3', { final res = await _pool.withResource(
'query': '""', () => client.get('search3', {
'songCount': size.toString(), 'query': '""',
'songOffset': offset.toString(), 'songCount': size.toString(),
'artistCount': '0', 'songOffset': offset.toString(),
'albumCount': '0', 'artistCount': '0',
}); 'albumCount': '0',
}),
);
final songs = res.xml.findAllElements('song'); final songs = res.xml.findAllElements('song');
offset += songs.length; offset += songs.length;

View File

@ -1,4 +1,3 @@
import 'package:collection/collection.dart';
import 'package:http/http.dart'; import 'package:http/http.dart';
import 'package:subtracks/sources/subsonic/client.dart'; import 'package:subtracks/sources/subsonic/client.dart';
import 'package:subtracks/sources/subsonic/source.dart'; import 'package:subtracks/sources/subsonic/source.dart';
@ -32,12 +31,21 @@ void main() {
password: 'password', password: 'password',
), ),
), ),
Server(
name: 'gonic',
client: SubsonicClient(
http: TestHttpClient(),
address: Uri.parse('http://localhost:4747/'),
username: 'admin',
password: 'admin',
),
),
]; ];
for (final Server(:name, :client) in clients) { for (final Server(:name, :client) in clients) {
group(name, () { group(name, () {
setUp(() async { setUp(() async {
source = SubsonicSource(client: client, maxBitrate: 196); source = SubsonicSource(client);
}); });
test('ping', () async { test('ping', () async {
@ -45,22 +53,34 @@ void main() {
}); });
test('allAlbums', () async { test('allAlbums', () async {
final items = (await source.allAlbums().toList()).flattened.toList(); final items = await source.allAlbums().toList();
expect(items.length, equals(3)); expect(items.length, equals(3));
}); });
test('allArtists', () async { test('allArtists', () async {
final items = (await source.allArtists().toList()).flattened.toList(); final items = await source.allArtists().toList();
expect(items.length, equals(2)); expect(items.length, equals(2));
}); });
test('allSongs', () async { test('allSongs', () async {
final items = (await source.allSongs().toList()).flattened.toList(); final items = await source.allSongs().toList();
expect(items.length, equals(20)); expect(items.length, equals(20));
}); });
test('allPlaylists', () async {
final items = await source.allPlaylists().toList();
expect(items.length, equals(0));
});
test('allPlaylistSongs', () async {
final items = await source.allPlaylistSongs().toList();
expect(items.length, equals(0));
});
}); });
} }
} }