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 _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` (the latter emits a repeated query param, e.g. several /// `songIdToAdd` for `updatePlaylist`). Uri _uri(String endpoint, [Map 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> _get( String endpoint, [ Map params = const {}, ]) async { final response = await _dio.getUri(_uri(endpoint, params)); final data = response.data; final Map body = data is String ? (jsonDecode(data) as Map).cast() : (data as Map).cast(); final inner = (body['subsonic-response'] as Map?)?.cast() ?? {}; final status = asString(inner['status']); if (status == 'failed') { final err = (inner['error'] as Map?)?.cast() ?? {}; 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 ping() async { try { await _get('ping'); return true; } on SubsonicError { rethrow; } catch (_) { return false; } } // ---- Browsing ----------------------------------------------------------- /// `getArtists` — the full artist index. Future getArtists() async { final r = await _get('getArtists'); final artists = (r['artists'] as Map?)?.cast() ?? const {}; return ArtistsResult.fromJson(artists); } /// `getArtist` — one artist with its albums. Future getArtist(String id) async { final r = await _get('getArtist', {'id': id}); return Artist.fromJson( (r['artist'] as Map).cast()); } /// `getAlbum` — one album with its songs. Future getAlbum(String id) async { final r = await _get('getAlbum', {'id': id}); return Album.fromJson((r['album'] as Map).cast()); } /// `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> 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() ?? const {}; return oneOrMany(list['album'], Album.fromJson); } // ---- Search ------------------------------------------------------------- /// `search3` — combined artist/album/song search. Future search3(String query) async { final r = await _get('search3', {'query': query}); final result = (r['searchResult3'] as Map?)?.cast() ?? const {}; return SearchResult3.fromJson(result); } // ---- Favorites / ratings ------------------------------------------------ /// `getStarred2` — the user's starred artists/albums/songs. Future getStarred2() async { final r = await _get('getStarred2'); final s = (r['starred2'] as Map?)?.cast() ?? const {}; return Starred2.fromJson(s); } /// `star` / `unstar`. Dispatches to the right param (id / albumId / artistId) /// exactly like Ratune's `set_starred` (`client.rs`). Future setStarred({ required bool starred, String? songId, String? albumId, String? artistId, }) async { final param = { 'id': ?songId, 'albumId': ?albumId, 'artistId': ?artistId, }; if (param.isEmpty) return; await _get(starred ? 'star' : 'unstar', param); } /// `setRating` — 1–5, or 0 to clear. Future 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 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> getPlaylists() async { final r = await _get('getPlaylists'); final list = (r['playlists'] as Map?)?.cast() ?? const {}; return oneOrMany(list['playlist'], Playlist.fromJson); } /// `getPlaylist` — one playlist including its full track list. Future getPlaylist(String id) async { final r = await _get('getPlaylist', {'id': id}); return PlaylistDetail.fromJson( (r['playlist'] as Map).cast()); } /// `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 createPlaylist(String name) async { final r = await _get('createPlaylist', {'name': name}); final p = (r['playlist'] as Map?)?.cast(); return p == null ? null : PlaylistDetail.fromJson(p); } /// `updatePlaylist` + one or more `songIdToAdd` — append tracks. Future addTracksToPlaylist(String playlistId, List songIds) async { if (songIds.isEmpty) return; await _get('updatePlaylist', { 'playlistId': playlistId, 'songIdToAdd': songIds, }); } /// `updatePlaylist` + `songIndexToRemove` — remove the track at [index]. Future removeTrackFromPlaylist(String playlistId, int index) async { await _get('updatePlaylist', { 'playlistId': playlistId, 'songIndexToRemove': '$index', }); } /// `updatePlaylist` + `name` — rename a playlist. Future renamePlaylist(String playlistId, String name) async { await _get('updatePlaylist', {'playlistId': playlistId, 'name': name}); } /// `deletePlaylist` — delete a playlist by id. Future 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)}', }); }