feat(jellyfin): the instance suggests a Jellyfin server, signed in to with a tap
An instance now names a Jellyfin server for its people to learn from (instance.info().jellyfinUrl): its operator's JELLYFIN_URL (`none`: none), else https://jellyfin. on the domain registered for its public host, as learn.halkyon.eu suggests jellyfin.halkyon.eu, once that answers as a Jellyfin server. The registered domain comes from Mozilla's Public Suffix List, kept in the server's data folder, so an instance at example.co.uk never names someone else's jellyfin.co.uk. The instance looks as it starts and again hourly when asked; the app never reaches for a guessed address. In the app, Add material offers From Jellyfin as soon as the instance suggests a server, before anyone signed in to one. The sign-in fills the server in and finds it by itself, and lists the people the server shows on its login screen: one without a password, as a demo's, signs in with a tap; one with a password is named in the form, which asks for it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
93b0c0a6b0
commit
85506ac7eb
17 changed files with 17321 additions and 17 deletions
|
|
@ -1749,6 +1749,9 @@
|
|||
"jellyfinAddressHint": "jellyfin.example.org",
|
||||
"jellyfinFind": "Weiter",
|
||||
"jellyfinUser": "Benutzername",
|
||||
"jellyfinPublicUsers": "Auf diesem Server",
|
||||
"jellyfinWithoutPassword": "Ohne Passwort: ein Tippen meldet an",
|
||||
"jellyfinWithPassword": "Mit Passwort",
|
||||
"jellyfinPassword": "Passwort",
|
||||
"jellyfinSignIn": "Anmelden",
|
||||
"jellyfinQuickConnect": "Mit einem Code anmelden",
|
||||
|
|
|
|||
|
|
@ -2061,6 +2061,9 @@
|
|||
"jellyfinAddressHint": "jellyfin.example.org",
|
||||
"jellyfinFind": "Continue",
|
||||
"jellyfinUser": "User name",
|
||||
"jellyfinPublicUsers": "On this server",
|
||||
"jellyfinWithoutPassword": "No password: one tap signs in",
|
||||
"jellyfinWithPassword": "With a password",
|
||||
"jellyfinPassword": "Password",
|
||||
"jellyfinSignIn": "Sign in",
|
||||
"jellyfinQuickConnect": "Sign in with a code",
|
||||
|
|
|
|||
|
|
@ -10335,6 +10335,24 @@ abstract class AppLocalizations {
|
|||
/// **'User name'**
|
||||
String get jellyfinUser;
|
||||
|
||||
/// No description provided for @jellyfinPublicUsers.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'On this server'**
|
||||
String get jellyfinPublicUsers;
|
||||
|
||||
/// No description provided for @jellyfinWithoutPassword.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No password: one tap signs in'**
|
||||
String get jellyfinWithoutPassword;
|
||||
|
||||
/// No description provided for @jellyfinWithPassword.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'With a password'**
|
||||
String get jellyfinWithPassword;
|
||||
|
||||
/// No description provided for @jellyfinPassword.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -6518,6 +6518,15 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
String get jellyfinUser => 'Benutzername';
|
||||
|
||||
@override
|
||||
String get jellyfinPublicUsers => 'Auf diesem Server';
|
||||
|
||||
@override
|
||||
String get jellyfinWithoutPassword => 'Ohne Passwort: ein Tippen meldet an';
|
||||
|
||||
@override
|
||||
String get jellyfinWithPassword => 'Mit Passwort';
|
||||
|
||||
@override
|
||||
String get jellyfinPassword => 'Passwort';
|
||||
|
||||
|
|
|
|||
|
|
@ -6462,6 +6462,15 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get jellyfinUser => 'User name';
|
||||
|
||||
@override
|
||||
String get jellyfinPublicUsers => 'On this server';
|
||||
|
||||
@override
|
||||
String get jellyfinWithoutPassword => 'No password: one tap signs in';
|
||||
|
||||
@override
|
||||
String get jellyfinWithPassword => 'With a password';
|
||||
|
||||
@override
|
||||
String get jellyfinPassword => 'Password';
|
||||
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ import 'package:halkyon_learn_flutter/src/feature/geo_map/data/geo_map_repositor
|
|||
import 'package:halkyon_learn_flutter/src/feature/jellyfin/data/jellyfin_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/jellyfin/data/jellyfin_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/jellyfin/data/jellyfin_store.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/jellyfin/data/jellyfin_suggestion.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/kanji/data/stroke_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_deletions.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
|
||||
|
|
@ -170,6 +171,9 @@ class Dependencies({
|
|||
/// device's, kept across instances.
|
||||
required final JellyfinRepository jellyfin,
|
||||
|
||||
/// The Jellyfin server the instance suggests, if any.
|
||||
required final JellyfinSuggestion jellyfinSuggestion,
|
||||
|
||||
/// Plays videos streamed from a server.
|
||||
required final MediaPlayers mediaPlayers,
|
||||
final Future<void> Function()? _onDispose,
|
||||
|
|
@ -231,6 +235,9 @@ class Dependencies({
|
|||
webSearch: WebSearch(),
|
||||
assistant: AssistantRepository(ServerpodAssistantGateway(client)),
|
||||
jellyfin: device.jellyfin,
|
||||
jellyfinSuggestion: JellyfinSuggestion(
|
||||
() async => (await client.instance.info()).jellyfinUrl,
|
||||
),
|
||||
mediaPlayers: PlatformMediaPlayers(),
|
||||
openPerson: (person) async {
|
||||
await previousClosed.timeout(
|
||||
|
|
|
|||
|
|
@ -46,6 +46,9 @@ abstract interface class JellyfinGateway() {
|
|||
/// Whether [server] lets devices sign in with a code (Quick Connect).
|
||||
Future<bool> quickConnectEnabled(JellyfinServer server);
|
||||
|
||||
/// The people [server] shows on its login screen.
|
||||
Future<List<JellyfinPublicUser>> publicUsers(JellyfinServer server);
|
||||
|
||||
/// Signs in to [server] as [user] with [password].
|
||||
Future<JellyfinAccount> signIn(
|
||||
JellyfinServer server,
|
||||
|
|
@ -229,6 +232,23 @@ class HttpJellyfinGateway(
|
|||
Future<bool> quickConnectEnabled(JellyfinServer server) async =>
|
||||
await _send('GET', _at(server.address, '/QuickConnect/Enabled')) == true;
|
||||
|
||||
@override
|
||||
Future<List<JellyfinPublicUser>> publicUsers(JellyfinServer server) async => [
|
||||
for (final user in _list(
|
||||
await _send('GET', _at(server.address, '/Users/Public')),
|
||||
))
|
||||
if (user case final Map<String, Object?> json)
|
||||
if ((_string(json, 'Id'), _string(json, 'Name')) case (
|
||||
final id?,
|
||||
final name?,
|
||||
))
|
||||
JellyfinPublicUser(
|
||||
id: id,
|
||||
name: name,
|
||||
hasPassword: json[_key(json, 'HasPassword')] != false,
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<JellyfinAccount> signIn(
|
||||
JellyfinServer server,
|
||||
|
|
|
|||
|
|
@ -96,6 +96,16 @@ class JellyfinRepository(
|
|||
await _keep(_state.value!.withAccount(account));
|
||||
}
|
||||
|
||||
/// The people [server] shows on its login screen; none where it can't
|
||||
/// say.
|
||||
Future<List<JellyfinPublicUser>> publicUsers(JellyfinServer server) async {
|
||||
try {
|
||||
return await (await _ready()).publicUsers(server);
|
||||
} on JellyfinException {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether [server] lets devices sign in with a code.
|
||||
Future<bool> quickConnectEnabled(JellyfinServer server) async =>
|
||||
await (await _ready()).quickConnectEnabled(server);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
import 'dart:developer';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// The Jellyfin server the instance suggests (decided 2026-10-13): its
|
||||
/// operator's, or `jellyfin.` on its own domain, which the instance has
|
||||
/// found to answer as one. The app never looks for a server itself; it asks
|
||||
/// the instance once, as the suggestion is first needed, and again only
|
||||
/// after asking failed. Null while unknown, or where there is none.
|
||||
class JellyfinSuggestion(final Future<String?> Function() _ask) {
|
||||
final _server = ValueNotifier<Uri?>(null);
|
||||
Future<void>? _asking;
|
||||
|
||||
/// The server suggested, once known.
|
||||
ValueListenable<Uri?> get server => _server;
|
||||
|
||||
/// Asks the instance, unless asked already.
|
||||
Future<void> look() => _asking ??= () async {
|
||||
try {
|
||||
_server.value = switch (await _ask()) {
|
||||
final url? => Uri.tryParse(url),
|
||||
null => null,
|
||||
};
|
||||
} on Object catch (error) {
|
||||
log(
|
||||
'Asking for the suggested server failed',
|
||||
name: 'Jellyfin',
|
||||
error: error,
|
||||
);
|
||||
_asking = null;
|
||||
}
|
||||
}();
|
||||
}
|
||||
|
|
@ -12,6 +12,17 @@ final class const JellyfinServer({
|
|||
List<Object?> get props => [address, id, name, version];
|
||||
}
|
||||
|
||||
/// Someone a Jellyfin server shows on its login screen, to sign in as
|
||||
/// with a tap: a demo's account has no password (decided 2026-10-13).
|
||||
final class const JellyfinPublicUser({
|
||||
required final String id,
|
||||
required final String name,
|
||||
required final bool hasPassword,
|
||||
}) with Equatable {
|
||||
@override
|
||||
List<Object?> get props => [id, name, hasPassword];
|
||||
}
|
||||
|
||||
/// Signed in to a Jellyfin server: as whom, and the token every request
|
||||
/// carries, which the device keeps to itself.
|
||||
final class const JellyfinAccount({
|
||||
|
|
|
|||
|
|
@ -25,6 +25,11 @@ String jellyfinProblemText(AppLocalizations l10n, JellyfinProblem problem) =>
|
|||
/// their Jellyfin apps (Quick Connect), where the server allows it. Signed
|
||||
/// in, the repository's state says so, and whatever shows this form shows
|
||||
/// the account instead.
|
||||
///
|
||||
/// A server the instance suggests is filled in and found at once, and the
|
||||
/// people a server shows on its login screen are listed (decided
|
||||
/// 2026-10-13): one without a password, as a demo's, signs in with a tap,
|
||||
/// one with a password is named in the form.
|
||||
class const JellyfinConnectForm({super.key}) extends StatefulWidget {
|
||||
@override
|
||||
State<JellyfinConnectForm> createState() => _JellyfinConnectFormState();
|
||||
|
|
@ -34,19 +39,40 @@ class _JellyfinConnectFormState() extends State<JellyfinConnectForm> {
|
|||
final _address = TextEditingController();
|
||||
final _user = TextEditingController();
|
||||
final _password = TextEditingController();
|
||||
final _passwordFocus = FocusNode();
|
||||
JellyfinServer? _server;
|
||||
List<JellyfinPublicUser> _publicUsers = const [];
|
||||
bool _quickConnectEnabled = false;
|
||||
({String code, String secret})? _quickConnect;
|
||||
Timer? _polling;
|
||||
var _busy = false;
|
||||
JellyfinProblem? _problem;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final suggestion = DependenciesScope.of(context).jellyfinSuggestion;
|
||||
unawaited(
|
||||
suggestion.look().then((_) {
|
||||
final suggested = suggestion.server.value;
|
||||
if (mounted &&
|
||||
suggested != null &&
|
||||
_server == null &&
|
||||
_address.text.isEmpty) {
|
||||
_address.text = '$suggested';
|
||||
unawaited(_find());
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_polling?.cancel();
|
||||
_address.dispose();
|
||||
_user.dispose();
|
||||
_password.dispose();
|
||||
_passwordFocus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
|
@ -72,10 +98,12 @@ class _JellyfinConnectFormState() extends State<JellyfinConnectForm> {
|
|||
final jellyfin = DependenciesScope.of(context).jellyfin;
|
||||
final server = await jellyfin.find(_address.text);
|
||||
final quickConnect = await jellyfin.quickConnectEnabled(server);
|
||||
final publicUsers = await jellyfin.publicUsers(server);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_server = server;
|
||||
_quickConnectEnabled = quickConnect;
|
||||
_publicUsers = publicUsers;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -86,6 +114,18 @@ class _JellyfinConnectFormState() extends State<JellyfinConnectForm> {
|
|||
).jellyfin.signIn(_server!, _user.text.trim(), _password.text),
|
||||
);
|
||||
|
||||
/// One of the people the server shows: signed in at once without a
|
||||
/// password, else named in the form, which asks for the password.
|
||||
void _choose(JellyfinPublicUser user) {
|
||||
_user.text = user.name;
|
||||
_password.clear();
|
||||
if (user.hasPassword) {
|
||||
_passwordFocus.requestFocus();
|
||||
} else {
|
||||
unawaited(_signIn());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _startQuickConnect() => _run(() async {
|
||||
final jellyfin = DependenciesScope.of(context).jellyfin;
|
||||
final server = _server!;
|
||||
|
|
@ -116,6 +156,7 @@ class _JellyfinConnectFormState() extends State<JellyfinConnectForm> {
|
|||
_polling?.cancel();
|
||||
setState(() {
|
||||
_server = null;
|
||||
_publicUsers = const [];
|
||||
_quickConnect = null;
|
||||
_problem = null;
|
||||
});
|
||||
|
|
@ -212,6 +253,32 @@ class _JellyfinConnectFormState() extends State<JellyfinConnectForm> {
|
|||
],
|
||||
),
|
||||
] else ...[
|
||||
if (_publicUsers.isNotEmpty) ...[
|
||||
Text(
|
||||
l10n.jellyfinPublicUsers,
|
||||
style: theme.textTheme.titleSmall,
|
||||
),
|
||||
for (final user in _publicUsers)
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: CircleAvatar(
|
||||
child: Text(user.name.characters.first.toUpperCase()),
|
||||
),
|
||||
title: Text(
|
||||
user.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(
|
||||
user.hasPassword
|
||||
? l10n.jellyfinWithPassword
|
||||
: l10n.jellyfinWithoutPassword,
|
||||
),
|
||||
enabled: !_busy,
|
||||
onTap: () => _choose(user),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
TextField(
|
||||
controller: _user,
|
||||
maxLength: TextLimits.name,
|
||||
|
|
@ -227,6 +294,7 @@ class _JellyfinConnectFormState() extends State<JellyfinConnectForm> {
|
|||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _password,
|
||||
focusNode: _passwordFocus,
|
||||
maxLength: TextLimits.phrase,
|
||||
buildCounter: nearLimitCounter,
|
||||
obscureText: true,
|
||||
|
|
|
|||
|
|
@ -1275,8 +1275,8 @@ class const _StudyPill({
|
|||
/// learning path, flashcards, a note, a PDF, words from a video or song of
|
||||
/// the person's Jellyfin server once this device is signed in to one (in
|
||||
/// Settings, decided 2026-10-13: as the assistant shows only once switched
|
||||
/// on), and words from the dictionary of its language where the instance
|
||||
/// has one. What is added shows in its own tab ([ContentTabCubit]): what is
|
||||
/// on) or the instance suggests one, and words from the dictionary of its
|
||||
/// language where the instance has one. What is added shows in its own tab ([ContentTabCubit]): what is
|
||||
/// written there once the person comes back, what is uploaded once it is
|
||||
/// in. Imports are under the options.
|
||||
class const _AddMaterial({
|
||||
|
|
@ -1293,18 +1293,31 @@ class _AddMaterialState() extends State<_AddMaterial> {
|
|||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final dependencies = DependenciesScope.of(context);
|
||||
// What the device keeps of Jellyfin, read once; no server is asked.
|
||||
unawaited(DependenciesScope.of(context).jellyfin.open());
|
||||
unawaited(dependencies.jellyfin.open());
|
||||
// Whether the instance suggests a server (it asks the instance alone).
|
||||
unawaited(dependencies.jellyfinSuggestion.look());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => ValueListenableBuilder(
|
||||
valueListenable: DependenciesScope.of(context).jellyfin.state,
|
||||
builder: (context, jellyfin, _) => StudysetDictionary(
|
||||
builder: (context, dictionary) =>
|
||||
_menu(context, dictionary, jellyfin: jellyfin?.account != null),
|
||||
),
|
||||
);
|
||||
Widget build(BuildContext context) {
|
||||
final dependencies = DependenciesScope.of(context);
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: dependencies.jellyfin.state,
|
||||
builder: (context, jellyfin, _) => ValueListenableBuilder(
|
||||
valueListenable: dependencies.jellyfinSuggestion.server,
|
||||
builder: (context, suggested, _) => StudysetDictionary(
|
||||
builder: (context, dictionary) => _menu(
|
||||
context,
|
||||
dictionary,
|
||||
// Signed in, or a server the instance suggests to sign in to.
|
||||
jellyfin: jellyfin?.account != null || suggested != null,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _menu(
|
||||
BuildContext context,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/jellyfin/data/jellyfin_suggestion.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/jellyfin/model/jellyfin.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/jellyfin/widget/watch_screen.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||
|
|
@ -21,7 +23,11 @@ void main() {
|
|||
store = MemoryJellyfinStore();
|
||||
});
|
||||
|
||||
Future<void> show(WidgetTester tester, String location) async {
|
||||
Future<void> show(
|
||||
WidgetTester tester,
|
||||
String location, {
|
||||
String? suggested,
|
||||
}) async {
|
||||
tester.view.physicalSize = const Size(1200, 1400);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
|
|
@ -44,6 +50,7 @@ void main() {
|
|||
dictionaryGateway: sampleDictionary(),
|
||||
jellyfinGateway: jellyfin,
|
||||
jellyfinStore: store,
|
||||
jellyfinSuggestion: JellyfinSuggestion(() async => suggested),
|
||||
),
|
||||
location: location,
|
||||
);
|
||||
|
|
@ -228,4 +235,57 @@ void main() {
|
|||
|
||||
expect(find.text('Libraries'), findsOneWidget);
|
||||
});
|
||||
|
||||
group('a Jellyfin server the instance suggests (decided 2026-10-13)', () {
|
||||
setUp(() {
|
||||
jellyfin.shownUsers = const [
|
||||
JellyfinPublicUser(id: 'user-2', name: 'Demo', hasPassword: false),
|
||||
JellyfinPublicUser(id: 'user-1', name: 'mika', hasPassword: true),
|
||||
];
|
||||
});
|
||||
|
||||
testWidgets('Add material offers it before signing in; the sign-in '
|
||||
"finds it by itself and lists the server's public people, one "
|
||||
'without a password signed in with a tap', (tester) async {
|
||||
await show(
|
||||
tester,
|
||||
RoutePaths.studyset('japanese'),
|
||||
suggested: 'https://jellyfin.local:8920',
|
||||
);
|
||||
await tester.tap(find.text('Add material'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('From Jellyfin'));
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Living room'), findsOneWidget);
|
||||
expect(find.text('Demo'), findsOneWidget);
|
||||
await tester.tap(find.text('Demo'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Libraries'), findsOneWidget);
|
||||
expect(store.kept?.account?.userName, 'Demo');
|
||||
});
|
||||
|
||||
testWidgets('one with a password is named in the form, which asks for '
|
||||
'it', (tester) async {
|
||||
await show(
|
||||
tester,
|
||||
RoutePaths.jellyfin('japanese'),
|
||||
suggested: 'https://jellyfin.local:8920',
|
||||
);
|
||||
|
||||
await tester.tap(find.text('mika'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
tester
|
||||
.widget<TextField>(find.widgetWithText(TextField, 'User name'))
|
||||
.controller!
|
||||
.text,
|
||||
'mika',
|
||||
);
|
||||
expect(store.kept?.account, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,12 @@ MockClient jellyfin12(List<http.Request> requests) => MockClient((
|
|||
'id': 'e39b17f2',
|
||||
'startupWizardCompleted': true,
|
||||
}),
|
||||
// The people the server shows on its login screen, one without a
|
||||
// password (a demo's), in either case of field names.
|
||||
('GET', '/Users/Public') => json([
|
||||
{'Name': 'Demo', 'Id': 'u-2', 'HasPassword': false},
|
||||
{'name': 'mika', 'id': 'u-1', 'hasPassword': true},
|
||||
]),
|
||||
('POST', '/Users/AuthenticateByName') =>
|
||||
(jsonDecode(request.body) as Map<String, Object?>)['Pw'] == 'secret'
|
||||
? json({
|
||||
|
|
@ -230,6 +236,18 @@ void main() {
|
|||
},
|
||||
);
|
||||
|
||||
test('lists the people a server shows on its login screen, and which '
|
||||
'need a password', () async {
|
||||
final server = await gateway.server(
|
||||
Uri.parse('http://jellyfin.local:8096'),
|
||||
);
|
||||
|
||||
expect(await gateway.publicUsers(server), const [
|
||||
JellyfinPublicUser(id: 'u-2', name: 'Demo', hasPassword: false),
|
||||
JellyfinPublicUser(id: 'u-1', name: 'mika', hasPassword: true),
|
||||
]);
|
||||
});
|
||||
|
||||
test('lists libraries of videos and of music, leaving out what is '
|
||||
'neither', () async {
|
||||
final account = await signIn();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/jellyfin/data/jellyfin_suggestion.dart';
|
||||
|
||||
void main() {
|
||||
test('asks the instance once, as it is first needed', () async {
|
||||
var asked = 0;
|
||||
final suggestion = JellyfinSuggestion(() async {
|
||||
asked++;
|
||||
return 'https://jellyfin.halkyon.eu/';
|
||||
});
|
||||
expect(suggestion.server.value, isNull);
|
||||
|
||||
await suggestion.look();
|
||||
await suggestion.look();
|
||||
|
||||
expect(asked, 1);
|
||||
expect(suggestion.server.value, Uri.parse('https://jellyfin.halkyon.eu/'));
|
||||
});
|
||||
|
||||
test('asks again only after asking failed; none named is none', () async {
|
||||
var asked = 0;
|
||||
final suggestion = JellyfinSuggestion(() async {
|
||||
asked++;
|
||||
if (asked == 1) {
|
||||
throw Exception('offline');
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
await suggestion.look();
|
||||
expect(suggestion.server.value, isNull);
|
||||
await suggestion.look();
|
||||
await suggestion.look();
|
||||
|
||||
expect(asked, 2);
|
||||
expect(suggestion.server.value, isNull);
|
||||
});
|
||||
}
|
||||
|
|
@ -32,6 +32,10 @@ class FakeJellyfinGateway({
|
|||
/// Whether the Quick Connect code was approved.
|
||||
bool approved = false;
|
||||
|
||||
/// The people the server shows on its login screen; those without a
|
||||
/// password sign in with none.
|
||||
List<JellyfinPublicUser> shownUsers = const [];
|
||||
|
||||
/// What was reported of playbacks: their positions, in order.
|
||||
final reports = <({Duration position, bool started, bool stopped})>[];
|
||||
|
||||
|
|
@ -47,10 +51,14 @@ class FakeJellyfinGateway({
|
|||
}
|
||||
}
|
||||
|
||||
JellyfinAccount _account(String token) => JellyfinAccount(
|
||||
JellyfinAccount _account(
|
||||
String token, {
|
||||
String userId = 'user-1',
|
||||
String userName = 'mika',
|
||||
}) => JellyfinAccount(
|
||||
server: fakeJellyfinServer,
|
||||
userId: 'user-1',
|
||||
userName: 'mika',
|
||||
userId: userId,
|
||||
userName: userName,
|
||||
token: token,
|
||||
);
|
||||
|
||||
|
|
@ -71,12 +79,21 @@ class FakeJellyfinGateway({
|
|||
@override
|
||||
Future<bool> quickConnectEnabled(JellyfinServer server) async => true;
|
||||
|
||||
@override
|
||||
Future<List<JellyfinPublicUser>> publicUsers(JellyfinServer server) async =>
|
||||
shownUsers;
|
||||
|
||||
@override
|
||||
Future<JellyfinAccount> signIn(
|
||||
JellyfinServer server,
|
||||
String user,
|
||||
String password,
|
||||
) async {
|
||||
for (final public in shownUsers) {
|
||||
if (public.name == user && !public.hasPassword && password.isEmpty) {
|
||||
return _account('token-3', userId: public.id, userName: public.name);
|
||||
}
|
||||
}
|
||||
if (user != 'mika' || password != 'secret') {
|
||||
throw JellyfinException(problem: JellyfinProblem.wrongCredentials);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ import 'package:halkyon_learn_flutter/src/feature/geo_map/data/geo_map_repositor
|
|||
import 'package:halkyon_learn_flutter/src/feature/jellyfin/data/jellyfin_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/jellyfin/data/jellyfin_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/jellyfin/data/jellyfin_store.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/jellyfin/data/jellyfin_suggestion.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/kanji/data/stroke_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_deletions.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_gateway.dart';
|
||||
|
|
@ -166,6 +167,7 @@ Dependencies testDependencies({
|
|||
AssistantGateway? assistantGateway,
|
||||
JellyfinGateway? jellyfinGateway,
|
||||
JellyfinStore? jellyfinStore,
|
||||
JellyfinSuggestion? jellyfinSuggestion,
|
||||
MediaPlayers? mediaPlayers,
|
||||
}) => Dependencies(
|
||||
serverName: 'learn.example.org',
|
||||
|
|
@ -203,6 +205,8 @@ Dependencies testDependencies({
|
|||
deviceName: 'Test',
|
||||
appVersion: appVersion.version,
|
||||
),
|
||||
jellyfinSuggestion:
|
||||
jellyfinSuggestion ?? JellyfinSuggestion(() async => null),
|
||||
mediaPlayers: mediaPlayers ?? FakeMediaPlayers(),
|
||||
openPerson:
|
||||
openPerson ??
|
||||
|
|
|
|||
Loading…
Reference in a new issue