mirror of
https://github.com/austinried/subtracks.git
synced 2026-02-10 15:02:42 +01:00
music source and client for subsonic
test fixture setup for navidrome
This commit is contained in:
85
lib/sources/subsonic/client.dart
Normal file
85
lib/sources/subsonic/client.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
65
lib/sources/subsonic/mapping.dart
Normal file
65
lib/sources/subsonic/mapping.dart
Normal 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,
|
||||
);
|
||||
203
lib/sources/subsonic/source.dart
Normal file
203
lib/sources/subsonic/source.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
40
lib/sources/subsonic/xml.dart
Normal file
40
lib/sources/subsonic/xml.dart
Normal 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';
|
||||
}
|
||||
Reference in New Issue
Block a user