This commit is contained in:
Forrest 2026-07-29 14:14:18 -04:00
commit d205277cdd
182 changed files with 22978 additions and 0 deletions

View 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)}',
});
}