init
This commit is contained in:
commit
d205277cdd
182 changed files with 22978 additions and 0 deletions
292
lib/subsonic/subsonic_client.dart
Normal file
292
lib/subsonic/subsonic_client.dart
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import 'json_helpers.dart';
|
||||
import 'models.dart';
|
||||
|
||||
/// A Subsonic API error (server returned `status: "failed"`), ported from
|
||||
/// `ratune-subsonic/src/error.rs`. Code 40 is the auth-failure code.
|
||||
class SubsonicError implements Exception {
|
||||
SubsonicError(this.code, this.message);
|
||||
|
||||
final int code;
|
||||
final String message;
|
||||
|
||||
static const int authErrorCode = 40;
|
||||
bool get isAuthFailure => code == authErrorCode;
|
||||
|
||||
@override
|
||||
String toString() => 'SubsonicError($code): $message';
|
||||
}
|
||||
|
||||
/// Subsonic HTTP client, ported from `ratune-subsonic/src/client.rs`.
|
||||
///
|
||||
/// Auth is the classic token scheme: a fresh random salt per request and
|
||||
/// `token = MD5(password + salt)`, sent as query params
|
||||
/// `u / t / s / v / c / f=json`. The password is never sent in the clear.
|
||||
class SubsonicClient {
|
||||
SubsonicClient({
|
||||
required String baseUrl,
|
||||
required this.username,
|
||||
required String password,
|
||||
Dio? dio,
|
||||
}) : baseUrl = _trimTrailingSlash(baseUrl),
|
||||
// ignore: prefer_initializing_formals — field is private, param can't be
|
||||
_password = password,
|
||||
_dio = dio ??
|
||||
Dio(BaseOptions(
|
||||
connectTimeout: const Duration(seconds: 30),
|
||||
receiveTimeout: const Duration(seconds: 30),
|
||||
headers: {'User-Agent': 'ratune-mobile'},
|
||||
));
|
||||
|
||||
final String baseUrl;
|
||||
final String username;
|
||||
final String _password;
|
||||
final Dio _dio;
|
||||
|
||||
static const String apiVersion = '1.16.1';
|
||||
static const String clientName = 'ratune-mobile';
|
||||
static const String _saltAlphabet =
|
||||
'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
|
||||
static String _trimTrailingSlash(String url) =>
|
||||
url.endsWith('/') ? url.substring(0, url.length - 1) : url;
|
||||
|
||||
/// Cryptographically-random 12-char salt (Dart's `Random.secure()` stands in
|
||||
/// for the platform CSPRNG; Ratune uses a weaker LCG here).
|
||||
String _makeSalt([int length = 12]) {
|
||||
final rng = Random.secure();
|
||||
return List.generate(
|
||||
length,
|
||||
(_) => _saltAlphabet[rng.nextInt(_saltAlphabet.length)],
|
||||
).join();
|
||||
}
|
||||
|
||||
String _makeToken(String salt) =>
|
||||
md5.convert(utf8.encode('$_password$salt')).toString();
|
||||
|
||||
/// The auth + protocol query params appended to every request.
|
||||
Map<String, String> _authParams() {
|
||||
final salt = _makeSalt();
|
||||
return {
|
||||
'u': username,
|
||||
't': _makeToken(salt),
|
||||
's': salt,
|
||||
'v': apiVersion,
|
||||
'c': clientName,
|
||||
'f': 'json',
|
||||
};
|
||||
}
|
||||
|
||||
/// Build a signed request URI. [params] values may be a `String` or an
|
||||
/// `Iterable<String>` (the latter emits a repeated query param, e.g. several
|
||||
/// `songIdToAdd` for `updatePlaylist`).
|
||||
Uri _uri(String endpoint, [Map<String, dynamic> params = const {}]) {
|
||||
return Uri.parse('$baseUrl/rest/$endpoint').replace(
|
||||
queryParameters: {..._authParams(), ...params},
|
||||
);
|
||||
}
|
||||
|
||||
/// Perform a GET and unwrap the `{"subsonic-response": {...}}` envelope,
|
||||
/// throwing [SubsonicError] on `status: "failed"`.
|
||||
Future<Map<String, dynamic>> _get(
|
||||
String endpoint, [
|
||||
Map<String, dynamic> params = const {},
|
||||
]) async {
|
||||
final response = await _dio.getUri(_uri(endpoint, params));
|
||||
final data = response.data;
|
||||
final Map<String, dynamic> body = data is String
|
||||
? (jsonDecode(data) as Map).cast<String, dynamic>()
|
||||
: (data as Map).cast<String, dynamic>();
|
||||
|
||||
final inner =
|
||||
(body['subsonic-response'] as Map?)?.cast<String, dynamic>() ?? {};
|
||||
final status = asString(inner['status']);
|
||||
if (status == 'failed') {
|
||||
final err = (inner['error'] as Map?)?.cast<String, dynamic>() ?? {};
|
||||
throw SubsonicError(
|
||||
asInt(err['code']) ?? -1,
|
||||
asString(err['message']) ?? 'Unknown Subsonic error',
|
||||
);
|
||||
}
|
||||
return inner;
|
||||
}
|
||||
|
||||
// ---- Connectivity -------------------------------------------------------
|
||||
|
||||
/// `ping` — returns true if the server responds ok with valid credentials.
|
||||
Future<bool> ping() async {
|
||||
try {
|
||||
await _get('ping');
|
||||
return true;
|
||||
} on SubsonicError {
|
||||
rethrow;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Browsing -----------------------------------------------------------
|
||||
|
||||
/// `getArtists` — the full artist index.
|
||||
Future<ArtistsResult> getArtists() async {
|
||||
final r = await _get('getArtists');
|
||||
final artists =
|
||||
(r['artists'] as Map?)?.cast<String, dynamic>() ?? const {};
|
||||
return ArtistsResult.fromJson(artists);
|
||||
}
|
||||
|
||||
/// `getArtist` — one artist with its albums.
|
||||
Future<Artist> getArtist(String id) async {
|
||||
final r = await _get('getArtist', {'id': id});
|
||||
return Artist.fromJson(
|
||||
(r['artist'] as Map).cast<String, dynamic>());
|
||||
}
|
||||
|
||||
/// `getAlbum` — one album with its songs.
|
||||
Future<Album> getAlbum(String id) async {
|
||||
final r = await _get('getAlbum', {'id': id});
|
||||
return Album.fromJson((r['album'] as Map).cast<String, dynamic>());
|
||||
}
|
||||
|
||||
/// `getAlbumList2` — a page of albums by [type] (`alphabeticalByName`,
|
||||
/// `newest`, `frequent`, …). Subsonic caps [size] at 500; page with [offset].
|
||||
/// Albums here carry metadata + cover art but no songs (use `getAlbum`).
|
||||
Future<List<Album>> getAlbumList2({
|
||||
String type = 'alphabeticalByName',
|
||||
int size = 500,
|
||||
int offset = 0,
|
||||
}) async {
|
||||
final r = await _get('getAlbumList2', {
|
||||
'type': type,
|
||||
'size': '${size.clamp(1, 500)}',
|
||||
'offset': '$offset',
|
||||
});
|
||||
final list = (r['albumList2'] as Map?)?.cast<String, dynamic>() ?? const {};
|
||||
return oneOrMany(list['album'], Album.fromJson);
|
||||
}
|
||||
|
||||
// ---- Search -------------------------------------------------------------
|
||||
|
||||
/// `search3` — combined artist/album/song search.
|
||||
Future<SearchResult3> search3(String query) async {
|
||||
final r = await _get('search3', {'query': query});
|
||||
final result =
|
||||
(r['searchResult3'] as Map?)?.cast<String, dynamic>() ?? const {};
|
||||
return SearchResult3.fromJson(result);
|
||||
}
|
||||
|
||||
// ---- Favorites / ratings ------------------------------------------------
|
||||
|
||||
/// `getStarred2` — the user's starred artists/albums/songs.
|
||||
Future<Starred2> getStarred2() async {
|
||||
final r = await _get('getStarred2');
|
||||
final s = (r['starred2'] as Map?)?.cast<String, dynamic>() ?? const {};
|
||||
return Starred2.fromJson(s);
|
||||
}
|
||||
|
||||
/// `star` / `unstar`. Dispatches to the right param (id / albumId / artistId)
|
||||
/// exactly like Ratune's `set_starred` (`client.rs`).
|
||||
Future<void> setStarred({
|
||||
required bool starred,
|
||||
String? songId,
|
||||
String? albumId,
|
||||
String? artistId,
|
||||
}) async {
|
||||
final param = <String, String>{
|
||||
'id': ?songId,
|
||||
'albumId': ?albumId,
|
||||
'artistId': ?artistId,
|
||||
};
|
||||
if (param.isEmpty) return;
|
||||
await _get(starred ? 'star' : 'unstar', param);
|
||||
}
|
||||
|
||||
/// `setRating` — 1–5, or 0 to clear.
|
||||
Future<void> setRating(String id, int rating) async {
|
||||
await _get('setRating', {'id': id, 'rating': '${rating.clamp(0, 5)}'});
|
||||
}
|
||||
|
||||
/// `scrobble` — record a play on the server (increments play counts / marks
|
||||
/// now-playing). `submission=true` is a completed listen.
|
||||
Future<void> scrobble(String id, {bool submission = true}) async {
|
||||
await _get('scrobble', {'id': id, 'submission': '$submission'});
|
||||
}
|
||||
|
||||
// ---- Playlists ----------------------------------------------------------
|
||||
// Ported from `ratune-subsonic/src/client.rs`. Track mutations all go through
|
||||
// `updatePlaylist` with `songIdToAdd` / `songIndexToRemove` / `name`.
|
||||
|
||||
/// `getPlaylists` — every playlist visible to the authenticated user.
|
||||
Future<List<Playlist>> getPlaylists() async {
|
||||
final r = await _get('getPlaylists');
|
||||
final list = (r['playlists'] as Map?)?.cast<String, dynamic>() ?? const {};
|
||||
return oneOrMany(list['playlist'], Playlist.fromJson);
|
||||
}
|
||||
|
||||
/// `getPlaylist` — one playlist including its full track list.
|
||||
Future<PlaylistDetail> getPlaylist(String id) async {
|
||||
final r = await _get('getPlaylist', {'id': id});
|
||||
return PlaylistDetail.fromJson(
|
||||
(r['playlist'] as Map).cast<String, dynamic>());
|
||||
}
|
||||
|
||||
/// `createPlaylist` — create an empty playlist. Navidrome echoes the created
|
||||
/// playlist under `playlist` (same shape as `getPlaylist`); returns it when
|
||||
/// present so the caller gets the new id, else null (caller refetches).
|
||||
Future<PlaylistDetail?> createPlaylist(String name) async {
|
||||
final r = await _get('createPlaylist', {'name': name});
|
||||
final p = (r['playlist'] as Map?)?.cast<String, dynamic>();
|
||||
return p == null ? null : PlaylistDetail.fromJson(p);
|
||||
}
|
||||
|
||||
/// `updatePlaylist` + one or more `songIdToAdd` — append tracks.
|
||||
Future<void> addTracksToPlaylist(String playlistId, List<String> songIds) async {
|
||||
if (songIds.isEmpty) return;
|
||||
await _get('updatePlaylist', {
|
||||
'playlistId': playlistId,
|
||||
'songIdToAdd': songIds,
|
||||
});
|
||||
}
|
||||
|
||||
/// `updatePlaylist` + `songIndexToRemove` — remove the track at [index].
|
||||
Future<void> removeTrackFromPlaylist(String playlistId, int index) async {
|
||||
await _get('updatePlaylist', {
|
||||
'playlistId': playlistId,
|
||||
'songIndexToRemove': '$index',
|
||||
});
|
||||
}
|
||||
|
||||
/// `updatePlaylist` + `name` — rename a playlist.
|
||||
Future<void> renamePlaylist(String playlistId, String name) async {
|
||||
await _get('updatePlaylist', {'playlistId': playlistId, 'name': name});
|
||||
}
|
||||
|
||||
/// `deletePlaylist` — delete a playlist by id.
|
||||
Future<void> deletePlaylist(String id) async {
|
||||
await _get('deletePlaylist', {'id': id});
|
||||
}
|
||||
|
||||
// ---- Stream / art URLs (self-contained signed URLs) --------------------
|
||||
|
||||
/// Signed streaming URL, handed straight to the audio engine (or the download
|
||||
/// manager, which fetches these bytes to disk). `maxBitRate == 0` means
|
||||
/// original / no transcode; [format] requests a specific transcode container
|
||||
/// (e.g. `mp3`, `opus`), or null for the server default / original.
|
||||
Uri streamUri(String id, {int maxBitRate = 0, String? format}) =>
|
||||
_uri('stream', {
|
||||
'id': id,
|
||||
if (maxBitRate > 0) 'maxBitRate': '$maxBitRate',
|
||||
if (format != null && format.isNotEmpty) 'format': format,
|
||||
});
|
||||
|
||||
/// Signed cover-art URL. [size] is clamped to Subsonic's 32–2048 range.
|
||||
Uri coverArtUri(String id, {int? size}) => _uri('getCoverArt', {
|
||||
'id': id,
|
||||
if (size != null) 'size': '${size.clamp(32, 2048)}',
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue