music source and client for subsonic

test fixture setup for navidrome
This commit is contained in:
austinried
2025-11-01 22:24:59 +09:00
parent 9f05ebb201
commit 3408a3988e
18 changed files with 1556 additions and 8 deletions

View File

@@ -0,0 +1,85 @@
import 'dart:convert';
import 'dart:math';
import 'package:crypto/crypto.dart';
import 'package:http/http.dart';
import 'package:xml/xml.dart';
import 'xml.dart';
class SubsonicClient {
SubsonicClient({
required this.http,
required this.address,
required this.username,
required this.password,
this.useTokenAuth = true,
});
final BaseClient http;
final Uri address;
final String username;
final String password;
final bool useTokenAuth;
Uri uri(
String method, [
Map<String, String?>? extraParams,
]) {
final pathSegments = [...address.pathSegments, 'rest', '$method.view'];
final queryParameters = {
..._params(),
...(extraParams ?? {}),
}..removeWhere((_, value) => value == null);
return Uri(
scheme: address.scheme,
host: address.host,
port: address.hasPort ? address.port : null,
pathSegments: pathSegments,
queryParameters: queryParameters,
);
}
Future<SubsonicResponse> get(
String method, [
Map<String, String?>? extraParams,
]) async {
final res = await http.get(uri(method, extraParams));
final subsonicResponse = SubsonicResponse(
XmlDocument.parse(utf8.decode(res.bodyBytes)),
);
if (subsonicResponse.status == Status.failed) {
final error = SubsonicException(subsonicResponse.xml);
throw error;
}
return subsonicResponse;
}
String _salt() {
final r = Random();
return String.fromCharCodes(
List.generate(4, (index) => r.nextInt(92) + 33),
);
}
Map<String, String> _params() {
final Map<String, String> p = {};
p['v'] = '1.13.0';
p['c'] = 'subtracks';
p['u'] = username;
if (useTokenAuth) {
p['s'] = _salt();
p['t'] = md5.convert(utf8.encode(password + p['s']!)).toString();
} else {
p['p'] = password;
}
return p;
}
}

View File

@@ -0,0 +1,65 @@
import 'package:xml/xml.dart';
import '../models.dart';
SourceArtist mapArtist(XmlElement e) => SourceArtist(
id: e.getAttribute('id')!,
name: e.getAttribute('name')!,
starred: DateTime.tryParse(e.getAttribute('starred').toString()),
);
SourceAlbum mapAlbum(
XmlElement e, {
int? frequentRank,
int? recentRank,
}) => SourceAlbum(
id: e.getAttribute('id')!,
artistId: e.getAttribute('artistId'),
name: e.getAttribute('name')!,
albumArtist: e.getAttribute('artist'),
created: DateTime.parse(e.getAttribute('created')!),
coverArt: e.getAttribute('coverArt'),
year: int.tryParse(e.getAttribute('year').toString()),
starred: DateTime.tryParse(e.getAttribute('starred').toString()),
genre: e.getAttribute('genre'),
frequentRank: frequentRank,
recentRank: recentRank,
);
SourcePlaylist mapPlaylist(XmlElement e) => SourcePlaylist(
id: e.getAttribute('id')!,
name: e.getAttribute('name')!,
comment: e.getAttribute('comment'),
coverArt: e.getAttribute('coverArt'),
created: DateTime.parse(e.getAttribute('created')!),
changed: DateTime.parse(e.getAttribute('changed')!),
owner: e.getAttribute('owner'),
public: bool.tryParse(e.getAttribute('public').toString()),
);
SourceSong mapSong(XmlElement e) => SourceSong(
id: e.getAttribute('id')!,
albumId: e.getAttribute('albumId'),
artistId: e.getAttribute('artistId'),
title: e.getAttribute('title')!,
album: e.getAttribute('album'),
artist: e.getAttribute('artist'),
duration: e.getAttribute('duration') != null
? Duration(
seconds: int.parse(e.getAttribute('duration').toString()),
)
: null,
track: int.tryParse(e.getAttribute('track').toString()),
disc: int.tryParse(e.getAttribute('discNumber').toString()),
starred: DateTime.tryParse(e.getAttribute('starred').toString()),
genre: e.getAttribute('genre'),
);
SourcePlaylistSong mapPlaylistSong(
int index,
XmlElement e,
) => SourcePlaylistSong(
playlistId: e.parentElement!.getAttribute('id')!,
songId: e.getAttribute('id')!,
position: index,
);

View File

@@ -0,0 +1,203 @@
import 'package:collection/collection.dart';
import 'package:pool/pool.dart';
import 'package:xml/xml.dart';
import 'package:xml/xml_events.dart';
import '../models.dart';
import '../music_source.dart';
import 'client.dart';
import 'mapping.dart';
class SubsonicSource implements MusicSource {
SubsonicSource({
required this.client,
required this.maxBitrate,
this.streamFormat,
});
final SubsonicClient client;
final int maxBitrate;
final String? streamFormat;
final _pool = Pool(10, timeout: const Duration(seconds: 60));
bool? _featureEmptyQuerySearch;
Future<bool> get supportsFastSongSync async {
if (_featureEmptyQuerySearch == null) {
final res = await client.get(
'search3',
{'query': '""', 'songCount': '1'},
);
_featureEmptyQuerySearch = res.xml.findAllElements('song').isNotEmpty;
}
return _featureEmptyQuerySearch!;
}
@override
Future<void> ping() async {
await client.get('ping');
}
@override
Stream<Iterable<SourceArtist>> allArtists() async* {
final res = await client.get('getArtists');
for (var artists in res.xml.findAllElements('artist').slices(200)) {
yield artists.map(mapArtist);
}
}
@override
Stream<Iterable<SourceAlbum>> allAlbums() async* {
final extras = await Future.wait([
_albumList(
'frequent',
).flatten().map((element) => element.getAttribute('id')!).toList(),
_albumList(
'recent',
).flatten().map((element) => element.getAttribute('id')!).toList(),
]);
final frequentlyPlayed = {
for (var i = 0; i < extras[0].length; i++) extras[0][i]: i,
};
final recentlyPlayed = {
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')!],
),
);
}
}
@override
Stream<Iterable<SourcePlaylist>> allPlaylists() async* {
final res = await client.get('getPlaylists');
for (var playlists in res.xml.findAllElements('playlist').slices(200)) {
yield playlists.map(mapPlaylist);
}
}
@override
Stream<Iterable<SourcePlaylistSong>> allPlaylistSongs() async* {
final allPlaylists = await 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});
return res.xml.findAllElements('entry').mapIndexed(mapPlaylistSong);
});
}
@override
Stream<Iterable<SourceSong>> allSongs() async* {
if (await supportsFastSongSync) {
await for (var songs in _songSearch()) {
yield 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);
});
}
}
}
@override
Uri streamUri(String songId) {
return client.uri('stream', {
'id': songId,
'estimateContentLength': true.toString(),
'maxBitRate': maxBitrate.toString(),
'format': streamFormat?.toString(),
});
}
@override
Uri downloadUri(String songId) {
return client.uri('download', {'id': songId});
}
@override
Uri coverArtUri(String id, {bool thumbnail = true}) {
final opts = {'id': id};
if (thumbnail) {
opts['size'] = 256.toString();
}
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 albums = res.xml.findAllElements('album');
offset += albums.length;
yield albums;
if (albums.length < size) {
break;
}
}
}
Stream<Iterable<XmlElement>> _songSearch() async* {
const size = 500;
var offset = 0;
while (true) {
final res = await client.get('search3', {
'query': '""',
'songCount': size.toString(),
'songOffset': offset.toString(),
'artistCount': '0',
'albumCount': '0',
});
final songs = res.xml.findAllElements('song');
offset += songs.length;
yield songs;
if (songs.length < size) {
break;
}
}
}
}

View File

@@ -0,0 +1,40 @@
import 'package:xml/xml.dart';
enum Status {
ok('ok'),
failed('failed');
const Status(this.value);
final String value;
}
class SubsonicResponse {
SubsonicResponse(XmlDocument xml) {
this.xml = xml.getElement('subsonic-response')!;
status = Status.values.byName(this.xml.getAttribute('status')!);
}
late Status status;
late XmlElement xml;
}
class SubsonicException implements Exception {
SubsonicException(this.xml) {
try {
final error = xml.getElement('error')!;
code = int.parse(error.getAttribute('code')!);
message = error.getAttribute('message')!;
} catch (err) {
code = -1;
message = 'Unknown error.';
}
}
final XmlElement xml;
late int code;
late String message;
@override
String toString() => 'SubsonicException [$code]: $message';
}