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,7 +8,7 @@ services:
navidrome:
image: deluan/navidrome:latest
ports:
- "4533:4533"
- 4533:4533
restart: unless-stopped
environment:
ND_LOGLEVEL: debug
@ -16,7 +16,26 @@ services:
- navidrome-data:/data
- 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:
deno-dir:
music:
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 name,
DateTime? starred,
Uri? smallImage,
Uri? largeImage,
}) = SourceArtist;
@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) {
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 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 _:
@ -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) {
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 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 _:
@ -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) {
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 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 _:
@ -224,12 +224,14 @@ return song(_that.id,_that.albumId,_that.artistId,_that.title,_that.artist,_that
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;
final String name;
final DateTime? starred;
final Uri? smallImage;
final Uri? largeImage;
/// Create a copy of SourceItem
/// with the given fields replaced by the non-null parameter values.
@ -241,16 +243,16 @@ $SourceArtistCopyWith<SourceArtist> get copyWith => _$SourceArtistCopyWithImpl<S
@override
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
int get hashCode => Object.hash(runtimeType,id,name,starred);
int get hashCode => Object.hash(runtimeType,id,name,starred,smallImage,largeImage);
@override
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;
@override @useResult
$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
/// 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(
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,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 {
Future<void> ping();
Stream<Iterable<SourceAlbum>> allAlbums();
Stream<Iterable<SourceArtist>> allArtists();
Stream<Iterable<SourcePlaylist>> allPlaylists();
Stream<Iterable<SourceSong>> allSongs();
Stream<Iterable<SourcePlaylistSong>> allPlaylistSongs();
Stream<SourceAlbum> allAlbums();
Stream<SourceArtist> allArtists();
Stream<SourcePlaylist> allPlaylists();
Stream<SourceSong> allSongs();
Stream<SourcePlaylistSong> allPlaylistSongs();
Uri streamUri(String songId);
Uri downloadUri(String songId);
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';
SourceArtist mapArtist(XmlElement e) => SourceArtist(
SourceArtist mapArtist(XmlElement e, XmlElement? info) => SourceArtist(
id: e.getAttribute('id')!,
name: e.getAttribute('name')!,
starred: DateTime.tryParse(e.getAttribute('starred').toString()),
smallImage: Uri.tryParse(info?.getElement('smallImageUrl')?.innerText ?? ''),
largeImage: Uri.tryParse(info?.getElement('largeImageUrl')?.innerText ?? ''),
);
SourceAlbum mapAlbum(

View File

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

View File

@ -1,4 +1,3 @@
import 'package:collection/collection.dart';
import 'package:http/http.dart';
import 'package:subtracks/sources/subsonic/client.dart';
import 'package:subtracks/sources/subsonic/source.dart';
@ -32,12 +31,21 @@ void main() {
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) {
group(name, () {
setUp(() async {
source = SubsonicSource(client: client, maxBitrate: 196);
source = SubsonicSource(client);
});
test('ping', () async {
@ -45,22 +53,34 @@ void main() {
});
test('allAlbums', () async {
final items = (await source.allAlbums().toList()).flattened.toList();
final items = await source.allAlbums().toList();
expect(items.length, equals(3));
});
test('allArtists', () async {
final items = (await source.allArtists().toList()).flattened.toList();
final items = await source.allArtists().toList();
expect(items.length, equals(2));
});
test('allSongs', () async {
final items = (await source.allSongs().toList()).flattened.toList();
final items = await source.allSongs().toList();
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));
});
});
}
}