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:
Tizian 「ティツィアーン」 2026-10-13 23:25:33 +02:00
commit 85506ac7eb
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
17 changed files with 17321 additions and 17 deletions

View file

@ -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",

View file

@ -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",

View file

@ -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:

View file

@ -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';

View file

@ -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';

View file

@ -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(

View file

@ -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,

View file

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

View file

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

View file

@ -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({

View file

@ -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,

View file

@ -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,

View file

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

View file

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

View file

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

View file

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

View file

@ -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 ??