init
This commit is contained in:
commit
d205277cdd
182 changed files with 22978 additions and 0 deletions
64
lib/subsonic/credentials.dart
Normal file
64
lib/subsonic/credentials.dart
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
/// A Subsonic server connection. The password is only ever held in memory and
|
||||
/// in the platform secure store — never in plain app storage (mirrors Ratune's
|
||||
/// keyring-first secret handling).
|
||||
class SubsonicCredentials {
|
||||
const SubsonicCredentials({
|
||||
required this.url,
|
||||
required this.username,
|
||||
required this.password,
|
||||
this.alias,
|
||||
});
|
||||
|
||||
final String url;
|
||||
final String username;
|
||||
final String password;
|
||||
|
||||
/// Optional display label shown instead of the raw URL (Ratune `[server].alias`).
|
||||
final String? alias;
|
||||
|
||||
String get display => alias?.isNotEmpty == true ? alias! : Uri.parse(url).host;
|
||||
}
|
||||
|
||||
/// Persists [SubsonicCredentials] to the platform secure store
|
||||
/// (Keychain / Keystore / libsecret).
|
||||
class CredentialStore {
|
||||
CredentialStore([FlutterSecureStorage? storage])
|
||||
: _storage = storage ?? const FlutterSecureStorage();
|
||||
|
||||
final FlutterSecureStorage _storage;
|
||||
|
||||
static const _kUrl = 'subsonic_url';
|
||||
static const _kUser = 'subsonic_username';
|
||||
static const _kPass = 'subsonic_password';
|
||||
static const _kAlias = 'subsonic_alias';
|
||||
|
||||
Future<void> save(SubsonicCredentials creds) async {
|
||||
await _storage.write(key: _kUrl, value: creds.url);
|
||||
await _storage.write(key: _kUser, value: creds.username);
|
||||
await _storage.write(key: _kPass, value: creds.password);
|
||||
await _storage.write(key: _kAlias, value: creds.alias ?? '');
|
||||
}
|
||||
|
||||
Future<SubsonicCredentials?> load() async {
|
||||
final url = await _storage.read(key: _kUrl);
|
||||
final user = await _storage.read(key: _kUser);
|
||||
final pass = await _storage.read(key: _kPass);
|
||||
if (url == null || user == null || pass == null) return null;
|
||||
final alias = await _storage.read(key: _kAlias);
|
||||
return SubsonicCredentials(
|
||||
url: url,
|
||||
username: user,
|
||||
password: pass,
|
||||
alias: (alias == null || alias.isEmpty) ? null : alias,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
await _storage.delete(key: _kUrl);
|
||||
await _storage.delete(key: _kUser);
|
||||
await _storage.delete(key: _kPass);
|
||||
await _storage.delete(key: _kAlias);
|
||||
}
|
||||
}
|
||||
59
lib/subsonic/json_helpers.dart
Normal file
59
lib/subsonic/json_helpers.dart
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
/// Defensive JSON helpers for the Subsonic protocol.
|
||||
///
|
||||
/// Subsonic servers are inconsistent (Ratune handles the same quirks in
|
||||
/// `ratune-subsonic/src/models.rs` via `OneOrMany` / `deserialize_flexible_id`):
|
||||
/// * a field that is sometimes a single object and sometimes an array
|
||||
/// * IDs that are sometimes strings and sometimes integers
|
||||
/// Every accessor below tolerates nulls and mixed types rather than throwing.
|
||||
library;
|
||||
|
||||
/// Coerce a value that may be a single object, a list, or null into a list of
|
||||
/// `T`, applying [fromJson] to each map element.
|
||||
List<T> oneOrMany<T>(
|
||||
dynamic value,
|
||||
T Function(Map<String, dynamic>) fromJson,
|
||||
) {
|
||||
if (value == null) return const [];
|
||||
if (value is List) {
|
||||
return value
|
||||
.whereType<Map>()
|
||||
.map((e) => fromJson(e.cast<String, dynamic>()))
|
||||
.toList();
|
||||
}
|
||||
if (value is Map) {
|
||||
return [fromJson(value.cast<String, dynamic>())];
|
||||
}
|
||||
return const [];
|
||||
}
|
||||
|
||||
/// Read a string that the server might encode as an int (e.g. IDs).
|
||||
String? asString(dynamic value) {
|
||||
if (value == null) return null;
|
||||
if (value is String) return value;
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
/// Read an int from a value that might be an int, num, or numeric string.
|
||||
int? asInt(dynamic value) {
|
||||
if (value == null) return null;
|
||||
if (value is int) return value;
|
||||
if (value is num) return value.toInt();
|
||||
if (value is String) return int.tryParse(value);
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Read a double from an int/num/numeric-string.
|
||||
double? asDouble(dynamic value) {
|
||||
if (value == null) return null;
|
||||
if (value is double) return value;
|
||||
if (value is num) return value.toDouble();
|
||||
if (value is String) return double.tryParse(value);
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Read a bool that might arrive as a bool or the string "true"/"false".
|
||||
bool asBool(dynamic value, {bool orElse = false}) {
|
||||
if (value is bool) return value;
|
||||
if (value is String) return value.toLowerCase() == 'true';
|
||||
return orElse;
|
||||
}
|
||||
331
lib/subsonic/models.dart
Normal file
331
lib/subsonic/models.dart
Normal file
|
|
@ -0,0 +1,331 @@
|
|||
import 'json_helpers.dart';
|
||||
|
||||
/// Subsonic domain models, ported from `ratune-subsonic/src/models.rs`.
|
||||
/// Nearly every field is nullable — servers omit fields freely. All parsing
|
||||
/// goes through the defensive helpers in `json_helpers.dart`.
|
||||
|
||||
class Song {
|
||||
Song({
|
||||
required this.id,
|
||||
this.title,
|
||||
this.album,
|
||||
this.artist,
|
||||
this.albumId,
|
||||
this.artistId,
|
||||
this.track,
|
||||
this.discNumber,
|
||||
this.year,
|
||||
this.genre,
|
||||
this.coverArt,
|
||||
this.duration,
|
||||
this.bitRate,
|
||||
this.contentType,
|
||||
this.suffix,
|
||||
this.size,
|
||||
this.path,
|
||||
this.starred = false,
|
||||
this.userRating,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String? title;
|
||||
final String? album;
|
||||
final String? artist;
|
||||
final String? albumId;
|
||||
final String? artistId;
|
||||
final int? track;
|
||||
final int? discNumber;
|
||||
final int? year;
|
||||
final String? genre;
|
||||
final String? coverArt;
|
||||
|
||||
/// Track length in seconds.
|
||||
final int? duration;
|
||||
final int? bitRate;
|
||||
final String? contentType;
|
||||
final String? suffix;
|
||||
final int? size;
|
||||
final String? path;
|
||||
final bool starred;
|
||||
|
||||
/// 1–5, or null if unrated.
|
||||
final int? userRating;
|
||||
|
||||
factory Song.fromJson(Map<String, dynamic> j) => Song(
|
||||
id: asString(j['id']) ?? '',
|
||||
title: asString(j['title']),
|
||||
album: asString(j['album']),
|
||||
artist: asString(j['artist']),
|
||||
albumId: asString(j['albumId']),
|
||||
artistId: asString(j['artistId']),
|
||||
track: asInt(j['track']),
|
||||
discNumber: asInt(j['discNumber']),
|
||||
year: asInt(j['year']),
|
||||
genre: asString(j['genre']),
|
||||
coverArt: asString(j['coverArt']),
|
||||
duration: asInt(j['duration']),
|
||||
bitRate: asInt(j['bitRate']),
|
||||
contentType: asString(j['contentType']),
|
||||
suffix: asString(j['suffix']),
|
||||
size: asInt(j['size']),
|
||||
path: asString(j['path']),
|
||||
starred: j['starred'] != null,
|
||||
userRating: asInt(j['userRating']),
|
||||
);
|
||||
|
||||
/// Round-trips through [Song.fromJson]. Backs the persisted library index
|
||||
/// (`library_index.dart`); only emits set fields to keep the cache compact.
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
if (title != null) 'title': title,
|
||||
if (album != null) 'album': album,
|
||||
if (artist != null) 'artist': artist,
|
||||
if (albumId != null) 'albumId': albumId,
|
||||
if (artistId != null) 'artistId': artistId,
|
||||
if (track != null) 'track': track,
|
||||
if (discNumber != null) 'discNumber': discNumber,
|
||||
if (year != null) 'year': year,
|
||||
if (genre != null) 'genre': genre,
|
||||
if (coverArt != null) 'coverArt': coverArt,
|
||||
if (duration != null) 'duration': duration,
|
||||
if (bitRate != null) 'bitRate': bitRate,
|
||||
if (contentType != null) 'contentType': contentType,
|
||||
if (suffix != null) 'suffix': suffix,
|
||||
if (size != null) 'size': size,
|
||||
if (path != null) 'path': path,
|
||||
if (starred) 'starred': true,
|
||||
if (userRating != null) 'userRating': userRating,
|
||||
};
|
||||
}
|
||||
|
||||
class Album {
|
||||
Album({
|
||||
required this.id,
|
||||
this.name,
|
||||
this.artist,
|
||||
this.artistId,
|
||||
this.coverArt,
|
||||
this.songCount,
|
||||
this.duration,
|
||||
this.year,
|
||||
this.genre,
|
||||
this.starred = false,
|
||||
this.userRating,
|
||||
this.songs = const [],
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String? name;
|
||||
final String? artist;
|
||||
final String? artistId;
|
||||
final String? coverArt;
|
||||
final int? songCount;
|
||||
final int? duration;
|
||||
final int? year;
|
||||
final String? genre;
|
||||
final bool starred;
|
||||
final int? userRating;
|
||||
|
||||
/// Populated only by `getAlbum`.
|
||||
final List<Song> songs;
|
||||
|
||||
factory Album.fromJson(Map<String, dynamic> j) => Album(
|
||||
id: asString(j['id']) ?? '',
|
||||
name: asString(j['name']) ?? asString(j['album']),
|
||||
artist: asString(j['artist']),
|
||||
artistId: asString(j['artistId']),
|
||||
coverArt: asString(j['coverArt']),
|
||||
songCount: asInt(j['songCount']),
|
||||
duration: asInt(j['duration']),
|
||||
year: asInt(j['year']),
|
||||
genre: asString(j['genre']),
|
||||
starred: j['starred'] != null,
|
||||
userRating: asInt(j['userRating']),
|
||||
songs: oneOrMany(j['song'], Song.fromJson),
|
||||
);
|
||||
}
|
||||
|
||||
class Artist {
|
||||
Artist({
|
||||
required this.id,
|
||||
this.name,
|
||||
this.albumCount,
|
||||
this.coverArt,
|
||||
this.starred = false,
|
||||
this.userRating,
|
||||
this.albums = const [],
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String? name;
|
||||
final int? albumCount;
|
||||
final String? coverArt;
|
||||
final bool starred;
|
||||
final int? userRating;
|
||||
|
||||
/// Populated only by `getArtist`.
|
||||
final List<Album> albums;
|
||||
|
||||
factory Artist.fromJson(Map<String, dynamic> j) => Artist(
|
||||
id: asString(j['id']) ?? '',
|
||||
name: asString(j['name']),
|
||||
albumCount: asInt(j['albumCount']),
|
||||
coverArt: asString(j['coverArt']),
|
||||
starred: j['starred'] != null,
|
||||
userRating: asInt(j['userRating']),
|
||||
albums: oneOrMany(j['album'], Album.fromJson),
|
||||
);
|
||||
}
|
||||
|
||||
/// A lettered bucket of artists from `getArtists` (`<index name="A">`).
|
||||
class ArtistIndex {
|
||||
ArtistIndex({required this.name, required this.artists});
|
||||
|
||||
final String name;
|
||||
final List<Artist> artists;
|
||||
|
||||
factory ArtistIndex.fromJson(Map<String, dynamic> j) => ArtistIndex(
|
||||
name: asString(j['name']) ?? '',
|
||||
artists: oneOrMany(j['artist'], Artist.fromJson),
|
||||
);
|
||||
}
|
||||
|
||||
/// Flattened result of `getArtists`.
|
||||
class ArtistsResult {
|
||||
ArtistsResult({required this.indexes});
|
||||
|
||||
final List<ArtistIndex> indexes;
|
||||
|
||||
List<Artist> get all => [for (final i in indexes) ...i.artists];
|
||||
|
||||
factory ArtistsResult.fromJson(Map<String, dynamic> j) => ArtistsResult(
|
||||
indexes: oneOrMany(j['index'], ArtistIndex.fromJson),
|
||||
);
|
||||
}
|
||||
|
||||
/// Result of `getStarred2` — the user's favorites.
|
||||
class Starred2 {
|
||||
Starred2({
|
||||
required this.artists,
|
||||
required this.albums,
|
||||
required this.songs,
|
||||
});
|
||||
|
||||
final List<Artist> artists;
|
||||
final List<Album> albums;
|
||||
final List<Song> songs;
|
||||
|
||||
factory Starred2.fromJson(Map<String, dynamic> j) => Starred2(
|
||||
artists: oneOrMany(j['artist'], Artist.fromJson),
|
||||
albums: oneOrMany(j['album'], Album.fromJson),
|
||||
songs: oneOrMany(j['song'], Song.fromJson),
|
||||
);
|
||||
}
|
||||
|
||||
/// Result of `search3`.
|
||||
class SearchResult3 {
|
||||
SearchResult3({
|
||||
required this.artists,
|
||||
required this.albums,
|
||||
required this.songs,
|
||||
});
|
||||
|
||||
final List<Artist> artists;
|
||||
final List<Album> albums;
|
||||
final List<Song> songs;
|
||||
|
||||
factory SearchResult3.fromJson(Map<String, dynamic> j) => SearchResult3(
|
||||
artists: oneOrMany(j['artist'], Artist.fromJson),
|
||||
albums: oneOrMany(j['album'], Album.fromJson),
|
||||
songs: oneOrMany(j['song'], Song.fromJson),
|
||||
);
|
||||
}
|
||||
|
||||
/// A playlist summary from `getPlaylists` (no tracks), ported from
|
||||
/// `ratune-subsonic/src/models.rs`. [toJson] backs the offline playlist mirror
|
||||
/// (`playlists/playlists.dart`).
|
||||
class Playlist {
|
||||
Playlist({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.songCount,
|
||||
this.duration,
|
||||
this.owner,
|
||||
this.public,
|
||||
this.coverArt,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final int? songCount;
|
||||
final int? duration;
|
||||
final String? owner;
|
||||
final bool? public;
|
||||
final String? coverArt;
|
||||
|
||||
factory Playlist.fromJson(Map<String, dynamic> j) => Playlist(
|
||||
id: asString(j['id']) ?? '',
|
||||
name: asString(j['name']) ?? 'Untitled',
|
||||
songCount: asInt(j['songCount']),
|
||||
duration: asInt(j['duration']),
|
||||
owner: asString(j['owner']),
|
||||
public: j['public'] is bool ? j['public'] as bool : null,
|
||||
coverArt: asString(j['coverArt']),
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
if (songCount != null) 'songCount': songCount,
|
||||
if (duration != null) 'duration': duration,
|
||||
if (owner != null) 'owner': owner,
|
||||
if (public != null) 'public': public,
|
||||
if (coverArt != null) 'coverArt': coverArt,
|
||||
};
|
||||
}
|
||||
|
||||
/// A playlist with its full track list from `getPlaylist`. The Subsonic API
|
||||
/// nests the tracks under the key `entry` (not `song`).
|
||||
class PlaylistDetail {
|
||||
PlaylistDetail({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.songCount,
|
||||
this.duration,
|
||||
this.coverArt,
|
||||
this.songs = const [],
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final int? songCount;
|
||||
final int? duration;
|
||||
final String? coverArt;
|
||||
final List<Song> songs;
|
||||
|
||||
factory PlaylistDetail.fromJson(Map<String, dynamic> j) => PlaylistDetail(
|
||||
id: asString(j['id']) ?? '',
|
||||
name: asString(j['name']) ?? 'Untitled',
|
||||
songCount: asInt(j['songCount']),
|
||||
duration: asInt(j['duration']),
|
||||
coverArt: asString(j['coverArt']),
|
||||
songs: oneOrMany(j['entry'], Song.fromJson),
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
if (songCount != null) 'songCount': songCount,
|
||||
if (duration != null) 'duration': duration,
|
||||
if (coverArt != null) 'coverArt': coverArt,
|
||||
'entry': songs.map((s) => s.toJson()).toList(),
|
||||
};
|
||||
|
||||
Playlist toSummary() => Playlist(
|
||||
id: id,
|
||||
name: name,
|
||||
songCount: songCount ?? songs.length,
|
||||
duration: duration,
|
||||
coverArt: coverArt,
|
||||
);
|
||||
}
|
||||
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