mobile-music/lib/subsonic/subsonic_client.dart
2026-08-04 19:29:58 -04:00

325 lines
12 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
/// `timbre-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 `timbre-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': 'timbre'},
));
final String baseUrl;
final String username;
final String _password;
final Dio _dio;
/// Auth params reused for asset URLs (cover art) so the URL is *stable*
/// across rebuilds. The per-request random salt in [_authParams] otherwise
/// makes every build produce a fresh cover-art URL, which defeats Flutter's
/// URL-keyed image cache and refetches the art on each rebuild — seen as a
/// flickering album cover, especially while mirroring a fast remote-state
/// stream (bug #4). Reusing one salt is safe under Subsonic's token scheme:
/// salt+token only prove password knowledge; the server treats neither as a
/// nonce. Computed once per client, so a credential change (new client)
/// still rotates it.
late final Map<String, String> _assetAuth = _authParams();
static const String apiVersion = '1.16.1';
static const String clientName = 'timbre';
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; Timbre 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 Timbre'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 `timbre-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});
}
/// `updatePlaylist` + `comment` — set a playlist's comment. Timbre uses this
/// to mark a playlist as a tag (`createPlaylist` can't set a comment inline).
Future<void> setPlaylistComment(String playlistId, String comment) async {
await _get(
'updatePlaylist', {'playlistId': playlistId, 'comment': comment});
}
/// `updatePlaylist` + `public` — share (server-wide) or unshare a playlist.
/// Navidrome honors this; other users then see it in their `getPlaylists`.
Future<void> setPlaylistPublic(String playlistId, bool isPublic) async {
await _get(
'updatePlaylist', {'playlistId': playlistId, 'public': '$isPublic'});
}
/// `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.
///
/// Uses the stable [_assetAuth] params (not the per-request salt) so the same
/// (id, size) always yields the same URL — required for the image cache to
/// hit and the art to hold steady across rebuilds (bug #4).
Uri coverArtUri(String id, {int? size}) =>
Uri.parse('$baseUrl/rest/getCoverArt').replace(
queryParameters: {
..._assetAuth,
'id': id,
if (size != null) 'size': '${size.clamp(32, 2048)}',
},
);
}