This commit is contained in:
Forrest 2026-07-29 22:41:38 -04:00
parent 981b4836f9
commit ed910748cb
34 changed files with 2054 additions and 153 deletions

View file

@ -1,7 +1,10 @@
import 'dart:convert';
import 'package:crypto/crypto.dart';
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
/// in the platform secure store — never in plain app storage (mirrors Timbre's
/// keyring-first secret handling).
class SubsonicCredentials {
const SubsonicCredentials({
@ -15,50 +18,121 @@ class SubsonicCredentials {
final String username;
final String password;
/// Optional display label shown instead of the raw URL (Ratune `[server].alias`).
/// Optional display label shown instead of the raw URL (Timbre `[server].alias`).
final String? alias;
String get display => alias?.isNotEmpty == true ? alias! : Uri.parse(url).host;
/// Stable identity for a saved server: `md5(baseUrl|username)`, matching the
/// cache key in `serverKeyProvider` so per-server data lines up. Two entries
/// with the same URL + username are the "same" server (different password /
/// alias overwrites it).
String get id {
final base = url.endsWith('/') ? url.substring(0, url.length - 1) : url;
return md5.convert(utf8.encode('$base|$username')).toString();
}
Map<String, dynamic> toJson() => {
'url': url,
'username': username,
'password': password,
if (alias != null && alias!.isNotEmpty) 'alias': alias,
};
factory SubsonicCredentials.fromJson(Map<String, dynamic> j) {
final alias = j['alias'] as String?;
return SubsonicCredentials(
url: j['url'] as String? ?? '',
username: j['username'] as String? ?? '',
password: j['password'] as String? ?? '',
alias: (alias == null || alias.isEmpty) ? null : alias,
);
}
}
/// Persists [SubsonicCredentials] to the platform secure store
/// (Keychain / Keystore / libsecret).
/// Persists a *list* of [SubsonicCredentials] plus a pointer to the active one
/// to the platform secure store (Keychain / Keystore / libsecret). Legacy
/// single-server installs are migrated into the list on first load.
class CredentialStore {
CredentialStore([FlutterSecureStorage? storage])
: _storage = storage ?? const FlutterSecureStorage();
final FlutterSecureStorage _storage;
/// JSON array of all saved servers.
static const _kServers = 'subsonic_servers';
/// Id ([SubsonicCredentials.id]) of the active server.
static const _kActive = 'subsonic_active';
// Legacy single-server keys, read once for migration then removed.
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 ?? '');
/// All saved servers. Empty if none configured. Migrates a legacy
/// single-server install into the new list format on first call.
Future<List<SubsonicCredentials>> loadAll() async {
final raw = await _storage.read(key: _kServers);
if (raw != null && raw.isNotEmpty) {
try {
final list = (jsonDecode(raw) as List)
.whereType<Map>()
.map((m) => SubsonicCredentials.fromJson(m.cast<String, dynamic>()))
.where((c) => c.url.isNotEmpty && c.username.isNotEmpty)
.toList();
return list;
} catch (_) {
return const [];
}
}
// Migrate a legacy single-server install, if present.
final migrated = await _migrateLegacy();
return migrated == null ? const [] : [migrated];
}
Future<SubsonicCredentials?> load() async {
/// Id of the active server, or null.
Future<String?> loadActiveId() => _storage.read(key: _kActive);
/// Overwrite the saved list and active pointer atomically-ish (two writes).
Future<void> saveAll(
List<SubsonicCredentials> servers, String? activeId) async {
await _storage.write(
key: _kServers,
value: jsonEncode([for (final s in servers) s.toJson()]),
);
if (activeId == null) {
await _storage.delete(key: _kActive);
} else {
await _storage.write(key: _kActive, value: activeId);
}
}
Future<SubsonicCredentials?> _migrateLegacy() 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(
final creds = SubsonicCredentials(
url: url,
username: user,
password: pass,
alias: (alias == null || alias.isEmpty) ? null : alias,
);
}
Future<void> clear() async {
// Write the new format, point at it, then drop the legacy keys.
await saveAll([creds], creds.id);
await _storage.delete(key: _kUrl);
await _storage.delete(key: _kUser);
await _storage.delete(key: _kPass);
await _storage.delete(key: _kAlias);
return creds;
}
/// Remove all saved servers and the active pointer.
Future<void> clear() async {
await _storage.delete(key: _kServers);
await _storage.delete(key: _kActive);
}
}

View file

@ -1,7 +1,7 @@
/// 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`):
/// Subsonic servers are inconsistent (Timbre handles the same quirks in
/// `timbre-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.

View file

@ -1,6 +1,6 @@
import 'json_helpers.dart';
/// Subsonic domain models, ported from `ratune-subsonic/src/models.rs`.
/// Subsonic domain models, ported from `timbre-subsonic/src/models.rs`.
/// Nearly every field is nullable — servers omit fields freely. All parsing
/// goes through the defensive helpers in `json_helpers.dart`.
@ -242,7 +242,7 @@ class SearchResult3 {
}
/// A playlist summary from `getPlaylists` (no tracks), ported from
/// `ratune-subsonic/src/models.rs`. [toJson] backs the offline playlist mirror
/// `timbre-subsonic/src/models.rs`. [toJson] backs the offline playlist mirror
/// (`playlists/playlists.dart`).
class Playlist {
Playlist({
@ -253,6 +253,7 @@ class Playlist {
this.owner,
this.public,
this.coverArt,
this.comment,
});
final String id;
@ -263,6 +264,10 @@ class Playlist {
final bool? public;
final String? coverArt;
/// Free-text playlist comment. Timbre stores a marker here to flag a playlist
/// as a "tag" (see `playlists.dart`'s `kTagMarker`); otherwise usually null.
final String? comment;
factory Playlist.fromJson(Map<String, dynamic> j) => Playlist(
id: asString(j['id']) ?? '',
name: asString(j['name']) ?? 'Untitled',
@ -271,6 +276,7 @@ class Playlist {
owner: asString(j['owner']),
public: j['public'] is bool ? j['public'] as bool : null,
coverArt: asString(j['coverArt']),
comment: asString(j['comment']),
);
Map<String, dynamic> toJson() => {
@ -281,6 +287,7 @@ class Playlist {
if (owner != null) 'owner': owner,
if (public != null) 'public': public,
if (coverArt != null) 'coverArt': coverArt,
if (comment != null) 'comment': comment,
};
}
@ -293,6 +300,7 @@ class PlaylistDetail {
this.songCount,
this.duration,
this.coverArt,
this.comment,
this.songs = const [],
});
@ -301,6 +309,9 @@ class PlaylistDetail {
final int? songCount;
final int? duration;
final String? coverArt;
/// See [Playlist.comment] — carries the Timbre tag marker when present.
final String? comment;
final List<Song> songs;
factory PlaylistDetail.fromJson(Map<String, dynamic> j) => PlaylistDetail(
@ -309,6 +320,7 @@ class PlaylistDetail {
songCount: asInt(j['songCount']),
duration: asInt(j['duration']),
coverArt: asString(j['coverArt']),
comment: asString(j['comment']),
songs: oneOrMany(j['entry'], Song.fromJson),
);
@ -318,6 +330,7 @@ class PlaylistDetail {
if (songCount != null) 'songCount': songCount,
if (duration != null) 'duration': duration,
if (coverArt != null) 'coverArt': coverArt,
if (comment != null) 'comment': comment,
'entry': songs.map((s) => s.toJson()).toList(),
};
@ -327,5 +340,6 @@ class PlaylistDetail {
songCount: songCount ?? songs.length,
duration: duration,
coverArt: coverArt,
comment: comment,
);
}

View file

@ -8,7 +8,7 @@ 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.
/// `timbre-subsonic/src/error.rs`. Code 40 is the auth-failure code.
class SubsonicError implements Exception {
SubsonicError(this.code, this.message);
@ -22,7 +22,7 @@ class SubsonicError implements Exception {
String toString() => 'SubsonicError($code): $message';
}
/// Subsonic HTTP client, ported from `ratune-subsonic/src/client.rs`.
/// 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
@ -57,7 +57,7 @@ class SubsonicClient {
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).
/// for the platform CSPRNG; Timbre uses a weaker LCG here).
String _makeSalt([int length = 12]) {
final rng = Random.secure();
return List.generate(
@ -190,7 +190,7 @@ class SubsonicClient {
}
/// `star` / `unstar`. Dispatches to the right param (id / albumId / artistId)
/// exactly like Ratune's `set_starred` (`client.rs`).
/// exactly like Timbre's `set_starred` (`client.rs`).
Future<void> setStarred({
required bool starred,
String? songId,
@ -218,7 +218,7 @@ class SubsonicClient {
}
// ---- Playlists ----------------------------------------------------------
// Ported from `ratune-subsonic/src/client.rs`. Track mutations all go through
// 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.
@ -266,6 +266,13 @@ class SubsonicClient {
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});
}
/// `deletePlaylist` — delete a playlist by id.
Future<void> deletePlaylist(String id) async {
await _get('deletePlaylist', {'id': id});