From c900c9750a14bb8cc29a2cf553a9b305acaec5ad Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Sun, 2 Nov 2025 11:56:17 +0900 Subject: [PATCH] stop streaming iterables add gonic to test servers setup gather artist image URLs on allArtists to remove weird Future interface for artist images move source options around --- compose.yaml | 21 ++++- lib/sources/models.dart | 2 + lib/sources/models.freezed.dart | 30 +++--- lib/sources/music_source.dart | 11 +-- lib/sources/subsonic/mapping.dart | 4 +- lib/sources/subsonic/source.dart | 146 ++++++++++++++++-------------- test/sources/subsonic_test.dart | 30 +++++- 7 files changed, 149 insertions(+), 95 deletions(-) diff --git a/compose.yaml b/compose.yaml index 1e05c24..8b5ed33 100644 --- a/compose.yaml +++ b/compose.yaml @@ -8,15 +8,34 @@ services: navidrome: image: deluan/navidrome:latest ports: - - "4533:4533" + - 4533:4533 restart: unless-stopped environment: ND_LOGLEVEL: debug volumes: - 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: diff --git a/lib/sources/models.dart b/lib/sources/models.dart index 7d78ee4..8e2fc1f 100644 --- a/lib/sources/models.dart +++ b/lib/sources/models.dart @@ -19,6 +19,8 @@ abstract class SourceItem with _$SourceItem { required String id, required String name, DateTime? starred, + Uri? smallImage, + Uri? largeImage, }) = SourceArtist; @With() diff --git a/lib/sources/models.freezed.dart b/lib/sources/models.freezed.dart index 7f66c3b..4e7743f 100644 --- a/lib/sources/models.freezed.dart +++ b/lib/sources/models.freezed.dart @@ -159,10 +159,10 @@ return song(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen({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 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({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({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? 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? 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 get copyWith => _$SourceArtistCopyWithImpl 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?, )); } diff --git a/lib/sources/music_source.dart b/lib/sources/music_source.dart index c4293e0..ab6ce0f 100644 --- a/lib/sources/music_source.dart +++ b/lib/sources/music_source.dart @@ -3,15 +3,14 @@ import 'models.dart'; abstract class MusicSource { Future ping(); - Stream> allAlbums(); - Stream> allArtists(); - Stream> allPlaylists(); - Stream> allSongs(); - Stream> allPlaylistSongs(); + Stream allAlbums(); + Stream allArtists(); + Stream allPlaylists(); + Stream allSongs(); + Stream allPlaylistSongs(); Uri streamUri(String songId); Uri downloadUri(String songId); Uri coverArtUri(String coverArtId, {bool thumbnail = true}); - Future artistArtUri(String artistId, {bool thumbnail = true}); } diff --git a/lib/sources/subsonic/mapping.dart b/lib/sources/subsonic/mapping.dart index ac25eb6..cfa366c 100644 --- a/lib/sources/subsonic/mapping.dart +++ b/lib/sources/subsonic/mapping.dart @@ -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( diff --git a/lib/sources/subsonic/source.dart b/lib/sources/subsonic/source.dart index f5d4b0d..a3b6960 100644 --- a/lib/sources/subsonic/source.dart +++ b/lib/sources/subsonic/source.dart @@ -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 get supportsFastSongSync async { @@ -41,23 +41,31 @@ class SubsonicSource implements MusicSource { } @override - Stream> allArtists() async* { - final res = await client.get('getArtists'); + Stream 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> allAlbums() async* { + Stream 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> allPlaylists() async* { - final res = await client.get('getPlaylists'); + Stream 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> allPlaylistSongs() async* { - final allPlaylists = await client.get('getPlaylists'); + Stream 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> allSongs() async* { + Stream 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 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> _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; diff --git a/test/sources/subsonic_test.dart b/test/sources/subsonic_test.dart index fcc01a3..76ed8da 100644 --- a/test/sources/subsonic_test.dart +++ b/test/sources/subsonic_test.dart @@ -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)); + }); }); } }