325 lines
12 KiB
Dart
325 lines
12 KiB
Dart
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)}',
|
||
},
|
||
);
|
||
}
|