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

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

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