feat(train): signing in to Jellyfin and marking playlists (ADR 0030, D47)
Music's decisions as D47, and its first part: the music screen from the account sheet, on Android, signs in to the person's Jellyfin with Quick Connect, else a password, and lists their playlists of music to mark for running. The token is in the keyring, the server and the marks in the person's music.json, nothing on Train's server. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
5ff9b2e053
commit
3336fe4eca
1 changed files with 2364 additions and 3 deletions
|
|
@ -0,0 +1,99 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:halkyon_train_flutter/src/feature/music/data/music_store.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
|
||||
/// A Jellyfin server for tests (ADR 0030, D47), at
|
||||
/// `https://music.example.org`: Ada signs in with the password `pw` or with
|
||||
/// Quick Connect once [approve]d; her token `token-<n>` holds until
|
||||
/// [revoke]d.
|
||||
class FakeJellyfin({
|
||||
/// Whether it signs in with Quick Connect.
|
||||
final bool quickConnect = true,
|
||||
}) {
|
||||
final requests = <http.Request>[];
|
||||
var _approved = false;
|
||||
var _tokens = 0;
|
||||
final _valid = <String>{};
|
||||
List<Map<String, Object?>> playlists = [
|
||||
{'Id': 'p1', 'Name': 'Tempo', 'MediaType': 'Audio', 'ChildCount': 42},
|
||||
{'Id': 'p2', 'Name': 'Easy', 'MediaType': 'Audio', 'ChildCount': 1},
|
||||
];
|
||||
|
||||
void approve() => _approved = true;
|
||||
|
||||
void revoke() => _valid.clear();
|
||||
|
||||
/// Tokens given and still valid.
|
||||
Set<String> get valid => {..._valid};
|
||||
|
||||
late final http.Client client = MockClient((request) async {
|
||||
requests.add(request);
|
||||
final token = RegExp(
|
||||
'Token="([^"]+)"',
|
||||
).firstMatch(request.headers['authorization'] ?? '')?.group(1);
|
||||
http.Response json(Object? body, [int status = 200]) =>
|
||||
http.Response(jsonEncode(body), status);
|
||||
String signIn() {
|
||||
final token = 'token-${++_tokens}';
|
||||
_valid.add(token);
|
||||
return token;
|
||||
}
|
||||
|
||||
switch ('${request.method} ${request.url.path}') {
|
||||
case 'GET /System/Info/Public':
|
||||
return json({
|
||||
'ServerName': 'Home',
|
||||
'Id': 'server-1',
|
||||
'Version': '10.10.7',
|
||||
});
|
||||
case 'GET /QuickConnect/Enabled':
|
||||
return json(quickConnect);
|
||||
case 'POST /QuickConnect/Initiate':
|
||||
return quickConnect
|
||||
? json({'Code': '123456', 'Secret': 'secret-1'})
|
||||
: json(null, 401);
|
||||
case 'GET /QuickConnect/Connect':
|
||||
return json({'Authenticated': _approved});
|
||||
case 'POST /Users/AuthenticateWithQuickConnect':
|
||||
return _approved
|
||||
? json({
|
||||
'AccessToken': signIn(),
|
||||
'User': {'Id': 'user-1', 'Name': 'Ada'},
|
||||
})
|
||||
: json(null, 401);
|
||||
case 'POST /Users/AuthenticateByName':
|
||||
final body = jsonDecode(request.body) as Map<String, Object?>;
|
||||
return body['Username'] == 'Ada' && body['Pw'] == 'pw'
|
||||
? json({
|
||||
'AccessToken': signIn(),
|
||||
'User': {'Id': 'user-1', 'Name': 'Ada'},
|
||||
})
|
||||
: json(null, 401);
|
||||
case 'GET /Items':
|
||||
return _valid.contains(token)
|
||||
? json({'Items': playlists})
|
||||
: json(null, 401);
|
||||
case 'POST /Sessions/Logout':
|
||||
_valid.remove(token);
|
||||
return http.Response('', 204);
|
||||
}
|
||||
return http.Response('', 404);
|
||||
});
|
||||
}
|
||||
|
||||
/// A [JellyfinTokenStore] in memory, with others' tokens in [others].
|
||||
class MemoryJellyfinTokenStore() implements JellyfinTokenStore {
|
||||
String? token;
|
||||
final others = <String>{'someone-else'};
|
||||
|
||||
@override
|
||||
Future<String?> read() async => token;
|
||||
|
||||
@override
|
||||
Future<void> keep(String? token) async => this.token = token;
|
||||
|
||||
@override
|
||||
Future<void> forgetOthers() async => others.clear();
|
||||
}
|
||||
Loading…
Reference in a new issue