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
3 changed files with 2364 additions and 3 deletions

View file

@ -0,0 +1,263 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/jellyfin_client.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
/// Jellyfin as Train asks it (ADR 0030, D47): what the server is, signing in
/// with Quick Connect or a password, and the person's playlists.
void main() {
final address = Uri.parse('https://music.example.org/jellyfin');
/// A Jellyfin server answering [routes] by path, as JSON; every request it
/// saw in [seen].
JellyfinClient serving(
Map<String, (int, Object?)> routes, {
List<http.Request>? seen,
}) => JellyfinClient(
MockClient((request) async {
seen?.add(request);
final route = routes['${request.method} ${request.url.path}'];
if (route == null) {
return http.Response('', 404);
}
return http.Response(
jsonEncode(route.$2),
route.$1,
headers: {'content-type': 'application/json'},
);
}),
deviceId: 'device-1',
version: '0.1.0',
);
group('an address as typed', () {
test('becomes https without a scheme, keeps its path, drops a trailing '
'slash', () {
expect(
jellyfinAddress(' music.example.org/jellyfin/ '),
Uri.parse('https://music.example.org/jellyfin'),
);
expect(
jellyfinAddress('http://192.168.1.4:8096'),
Uri.parse('http://192.168.1.4:8096'),
);
});
test('is none without a host or with another scheme', () {
expect(jellyfinAddress(''), isNull);
expect(jellyfinAddress('ftp://music.example.org'), isNull);
expect(jellyfinAddress('https://'), isNull);
});
});
test('a server says its name and whether Quick Connect is on', () async {
final seen = <http.Request>[];
final client = serving({
'GET /jellyfin/System/Info/Public': (
200,
{'ServerName': 'Home', 'Id': 'server-1', 'Version': '10.10.7'},
),
'GET /jellyfin/QuickConnect/Enabled': (200, true),
}, seen: seen);
final server = await client.server(address);
expect(server.name, 'Home');
expect(server.id, 'server-1');
expect(server.quickConnect, isTrue);
expect(
seen.first.headers['authorization'],
'MediaBrowser Client="Halkyon Train", Device="Android", '
'DeviceId="device-1", Version="0.1.0"',
);
});
test("what answers without Jellyfin's basics is not Jellyfin, and what "
"doesn't answer is unreachable", () async {
await expectLater(
serving({
'GET /jellyfin/System/Info/Public': (200, {'hello': 'world'}),
}).server(address),
throwsA(
isA<JellyfinException>().having(
(e) => e.failure,
'failure',
JellyfinFailure.notJellyfin,
),
),
);
await expectLater(
serving({}).server(address),
throwsA(
isA<JellyfinException>().having(
(e) => e.failure,
'failure',
JellyfinFailure.notJellyfin,
),
),
);
final down = JellyfinClient(
MockClient((request) => throw http.ClientException('down')),
deviceId: 'device-1',
version: '0.1.0',
);
await expectLater(
down.server(address),
throwsA(
isA<JellyfinException>().having(
(e) => e.failure,
'failure',
JellyfinFailure.unreachable,
),
),
);
});
test(
'Quick Connect: a code to show, approved, then a token of its own',
() async {
final seen = <http.Request>[];
var approved = false;
final client = JellyfinClient(
MockClient((request) async {
seen.add(request);
final body = switch ('${request.method} ${request.url.path}') {
'POST /jellyfin/QuickConnect/Initiate' => {
'Secret': 'secret-1',
'Code': '123456',
},
'GET /jellyfin/QuickConnect/Connect' => {'Authenticated': approved},
'POST /jellyfin/Users/AuthenticateWithQuickConnect' => {
'AccessToken': 'token-1',
'User': {'Id': 'user-1', 'Name': 'Ada'},
},
_ => null,
};
return http.Response(jsonEncode(body), body == null ? 404 : 200);
}),
deviceId: 'device-1',
version: '0.1.0',
);
final code = await client.initiateQuickConnect(address);
expect(code.code, '123456');
expect(await client.quickConnectApproved(address, code.secret), isFalse);
approved = true;
expect(await client.quickConnectApproved(address, code.secret), isTrue);
final signedIn = await client.authenticateWithQuickConnect(
address,
code.secret,
);
expect(signedIn, (token: 'token-1', userId: 'user-1', userName: 'Ada'));
expect(seen[1].url.queryParameters, {'Secret': 'secret-1'});
expect(jsonDecode(seen.last.body), {'Secret': 'secret-1'});
},
);
test('Quick Connect switched off is said so', () async {
await expectLater(
serving({
'POST /jellyfin/QuickConnect/Initiate': (401, null),
}).initiateQuickConnect(address),
throwsA(
isA<JellyfinException>().having(
(e) => e.failure,
'failure',
JellyfinFailure.quickConnectOff,
),
),
);
});
test('a password signs in; a wrong one is refused', () async {
final seen = <http.Request>[];
final client = serving({
'POST /jellyfin/Users/AuthenticateByName': (
200,
{
'AccessToken': 'token-2',
'User': {'Id': 'user-2', 'Name': 'Grace'},
},
),
}, seen: seen);
expect(
await client.authenticateByName(address, 'Grace', 'pw'),
(token: 'token-2', userId: 'user-2', userName: 'Grace'),
);
expect(jsonDecode(seen.single.body), {'Username': 'Grace', 'Pw': 'pw'});
await expectLater(
serving({
'POST /jellyfin/Users/AuthenticateByName': (401, null),
}).authenticateByName(address, 'Grace', 'wrong'),
throwsA(
isA<JellyfinException>().having(
(e) => e.failure,
'failure',
JellyfinFailure.refused,
),
),
);
});
test(
"the person's playlists of music, with the token; videos' left out",
() async {
final seen = <http.Request>[];
final client = serving({
'GET /jellyfin/Items': (
200,
{
'Items': [
{
'Id': 'p1',
'Name': 'Tempo',
'MediaType': 'Audio',
'ChildCount': 42,
},
{'Id': 'p2', 'Name': 'Films', 'MediaType': 'Video'},
{'Id': 'p3', 'Name': 'Old'},
],
},
),
}, seen: seen);
const session = (token: 'token-1', userId: 'user-1');
final playlists = await client.playlists(address, session);
expect(playlists, const [
JellyfinPlaylist(id: 'p1', name: 'Tempo', tracks: 42),
JellyfinPlaylist(id: 'p3', name: 'Old'),
]);
expect(seen.single.url.queryParameters, {
'userId': 'user-1',
'includeItemTypes': 'Playlist',
'recursive': 'true',
'sortBy': 'SortName',
'fields': 'ChildCount',
});
expect(
seen.single.headers['authorization'],
endsWith(', Token="token-1"'),
);
},
);
test('a token Jellyfin no longer takes is refused', () async {
await expectLater(
serving({
'GET /jellyfin/Items': (401, null),
}).playlists(address, (token: 'old', userId: 'user-1')),
throwsA(
isA<JellyfinException>().having(
(e) => e.failure,
'failure',
JellyfinFailure.refused,
),
),
);
});
}

View file

@ -0,0 +1,126 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/jellyfin_client.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/music_library.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/music_store.dart';
import '../../support/fake_jellyfin.dart';
/// The person's Jellyfin on this phone (ADR 0030, D47): signed in, the
/// token in the keyring and nothing else of it in the file, the playlists
/// marked, signed out.
void main() {
late Directory folder;
late File file;
late FakeJellyfin jellyfin;
late MemoryJellyfinTokenStore tokens;
final address = Uri.parse('https://music.example.org');
setUp(() {
folder = Directory.systemTemp.createTempSync('music_');
file = File('${folder.path}/music.json');
jellyfin = FakeJellyfin();
tokens = MemoryJellyfinTokenStore();
});
tearDown(() => folder.deleteSync(recursive: true));
MusicLibrary open() => MusicLibrary(
MusicStore(file),
tokens,
version: '0.1.0',
client: jellyfin.client,
);
test('a device ID is made once and kept, and Jellyfin is told it', () async {
final first = open();
final id = first.settings.value.deviceId;
expect(id, hasLength(32));
expect(open().settings.value.deviceId, id);
await first.find(address);
expect(
jellyfin.requests.first.headers['authorization'],
contains('DeviceId="$id"'),
);
});
test('signed in with a password: the token in the keyring, never in the '
'file; playlists marked and kept', () async {
final library = open();
final server = await library.find(address);
await library.signInWithPassword(server, 'Ada', 'pw');
expect(tokens.token, 'token-1');
expect(file.readAsStringSync(), isNot(contains('token-1')));
expect(library.settings.value.signedIn, isTrue);
expect(library.settings.value.userName, 'Ada');
expect(library.settings.value.serverName, 'Home');
final playlists = await library.playlists();
library.mark(playlists.first, on: true);
expect(open().settings.value.playlists, {'p1': 'Tempo'});
library.mark(playlists.first, on: false);
expect(open().settings.value.playlists, isEmpty);
});
test('a wrong password is refused, and nothing is kept', () async {
final library = open();
final server = await library.find(address);
await expectLater(
library.signInWithPassword(server, 'Ada', 'wrong'),
throwsA(isA<JellyfinException>()),
);
expect(tokens.token, isNull);
expect(library.settings.value.signedIn, isFalse);
});
test('signing out ends the token on the server and forgets it and the '
'playlists here', () async {
final library = open();
await library.signInWithPassword(await library.find(address), 'Ada', 'pw');
library.mark(const JellyfinPlaylist(id: 'p1', name: 'Tempo'), on: true);
await library.signOut();
expect(jellyfin.valid, isEmpty);
expect(tokens.token, isNull);
expect(library.settings.value.signedIn, isFalse);
expect(library.settings.value.playlists, isEmpty);
});
test('a token Jellyfin took back signs the person out here', () async {
final library = open();
await library.signInWithPassword(await library.find(address), 'Ada', 'pw');
jellyfin.revoke();
await expectLater(library.playlists(), throwsA(isA<JellyfinException>()));
expect(tokens.token, isNull);
expect(library.settings.value.signedIn, isFalse);
});
test(
"opening forgets others' tokens, and a sign-in whose token is gone",
() async {
final library = open();
await library.signInWithPassword(
await library.find(address),
'Ada',
'pw',
);
tokens.token = null;
final again = open();
await again.open();
expect(tokens.others, isEmpty);
expect(again.settings.value.signedIn, isFalse);
},
);
test("a file that can't be read is none", () {
file.writeAsStringSync('{not json');
expect(open().settings.value.signedIn, isFalse);
});
}

View file

@ -0,0 +1,159 @@
import 'dart:io';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
import 'package:halkyon_train_flutter/src/feature/music/bloc/music_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/music_library.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/music_store.dart';
import 'package:halkyon_train_flutter/src/feature/music/widget/music_screen.dart';
import 'package:material_ui/material_ui.dart';
import '../../support/fake_jellyfin.dart';
/// The music screen (ADR 0030, D47): the server's address, Quick Connect's
/// code until it is approved, else the password; then the playlists marked.
void main() {
late Directory folder;
late MemoryJellyfinTokenStore tokens;
setUp(() {
folder = Directory.systemTemp.createTempSync('music_');
tokens = MemoryJellyfinTokenStore();
});
tearDown(() => folder.deleteSync(recursive: true));
Future<MusicLibrary> show(WidgetTester tester, FakeJellyfin jellyfin) async {
final library = MusicLibrary(
MusicStore(File('${folder.path}/music.json')),
tokens,
version: '0.1.0',
client: jellyfin.client,
);
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('en'),
home: BlocProvider(
create: (context) => MusicCubit(
library,
poll: const Duration(milliseconds: 100),
)..open(),
child: const MusicScreen(),
),
),
);
await tester.runAsync(() => Future<void>.delayed(Duration.zero));
await tester.pumpAndSettle();
return library;
}
/// Lets the fake server's answers and the screen's timers run.
Future<void> settle(WidgetTester tester) async {
for (var i = 0; i < 5; i++) {
await tester.runAsync(() => Future<void>.delayed(Duration.zero));
await tester.pump(const Duration(milliseconds: 150));
}
}
testWidgets('Quick Connect: the code shown until approved, then the '
"person's playlists, one marked", (tester) async {
final jellyfin = FakeJellyfin();
final library = await show(tester, jellyfin);
await tester.enterText(find.byType(TextField), 'music.example.org');
await tester.tap(find.text('Connect'));
await settle(tester);
expect(find.text('123456'), findsOneWidget);
expect(find.textContaining('To sign in to Home'), findsOneWidget);
jellyfin.approve();
await settle(tester);
expect(find.text('Signed in as Ada'), findsOneWidget);
expect(find.text('Tempo'), findsOneWidget);
expect(find.text('42 tracks'), findsOneWidget);
await tester.tap(find.text('Tempo'));
await tester.pump();
expect(library.settings.value.playlists, {'p1': 'Tempo'});
await tester.tap(find.text('Sign out'));
await settle(tester);
expect(find.text('Jellyfin server'), findsOneWidget);
expect(tokens.token, isNull);
});
testWidgets('without Quick Connect, the password; a wrong one said so', (
tester,
) async {
await show(tester, FakeJellyfin(quickConnect: false));
await tester.enterText(find.byType(TextField), 'music.example.org');
await tester.tap(find.text('Connect'));
await settle(tester);
expect(find.text('User name'), findsOneWidget);
await tester.enterText(find.byType(TextField).first, 'Ada');
await tester.enterText(find.byType(TextField).last, 'wrong');
await tester.tap(find.text('Sign in'));
await settle(tester);
expect(find.textContaining('Jellyfin refused'), findsOneWidget);
await tester.enterText(find.byType(TextField).last, 'pw');
await tester.tap(find.text('Sign in'));
await settle(tester);
expect(find.text('Signed in as Ada'), findsOneWidget);
});
testWidgets('an address that names no server is said so, and nothing is '
'asked', (tester) async {
final jellyfin = FakeJellyfin();
await show(tester, jellyfin);
await tester.enterText(find.byType(TextField), 'ftp://x');
await tester.tap(find.text('Connect'));
await tester.pump();
expect(find.textContaining("Enter the server's address"), findsOneWidget);
expect(jellyfin.requests, isEmpty);
});
testWidgets('a code not approved in time expires, and a new one is asked '
'for', (tester) async {
final jellyfin = FakeJellyfin();
final library = MusicLibrary(
MusicStore(File('${folder.path}/music.json')),
tokens,
version: '0.1.0',
client: jellyfin.client,
);
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('en'),
home: BlocProvider(
create: (context) => MusicCubit(
library,
poll: const Duration(milliseconds: 100),
expiry: Duration.zero,
)..open(),
child: const MusicScreen(),
),
),
);
await settle(tester);
await tester.enterText(find.byType(TextField), 'music.example.org');
await tester.tap(find.text('Connect'));
await settle(tester);
expect(find.text('The code has expired.'), findsOneWidget);
await tester.tap(find.text('New code'));
await tester.runAsync(() => Future<void>.delayed(Duration.zero));
await tester.pump();
expect(find.text('123456'), findsOneWidget);
});
}