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:
Tizian 「ティツィアーン」 2026-10-29 12:36:32 +01:00
commit 3336fe4eca
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -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();
}