Files
sync_replay_project/apps/desktop/lib/src/jellyfin/sign_in.dart
T
foxyzandClaude Opus 5.5 773a04369e Jellyfin in the app: account linking and library browser
Integrations links several Jellyfin servers (Quick Connect or
password, app-obtained tokens only, status/reconnect/sign out). The
Host tab's Jellyfin source is a full browser: continue watching, next
up, latest per library, library grids with sort/filter/infinite
scroll, series pages with smart resume/next-up, details, grouped
search, inspector pane on wide screens, keyboard navigation.
MediaPicker selection is now a sealed PickedMedia. Hosting a Jellyfin
pick is interim (host's stream URL) until per-viewer playback lands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 02:05:46 +03:00

183 lines
5.8 KiB
Dart

import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:sync_relay_core/core.dart';
import 'accounts.dart';
enum JellyfinSignInStep {
/// Typing the server address.
address,
probing,
/// Server found: choose Quick Connect or password.
method,
/// Code shown, polling until the user approves it in Jellyfin.
quickConnect,
password,
signingIn,
/// Linked; [JellyfinSignIn.linked] is the account.
done,
}
/// Add-server / reconnect flow: address -> /System/Info/Public -> Quick
/// Connect (code + poll, cancellable) or username/password -> linked in
/// [JellyfinAccounts]. UI-free so tests drive it with a fake transport.
class JellyfinSignIn extends ChangeNotifier {
JellyfinSignIn(this.accounts, {this.pollInterval = const Duration(seconds: 3), this.reconnect});
final JellyfinAccounts accounts;
final Duration pollInterval;
/// Set when re-signing an expired account: the address is prefilled.
final JellyfinAccount? reconnect;
JellyfinSignInStep _step = JellyfinSignInStep.address;
String? _error;
JellyfinServerInfo? _server;
bool _quickConnectAvailable = false;
String? _code;
JellyfinAccount? _linked;
JellyfinClient? _client;
int _gen = 0;
bool _disposed = false;
JellyfinSignInStep get step => _step;
String? get error => _error;
JellyfinServerInfo? get server => _server;
String? get baseUrl => _client?.baseUrl;
bool get quickConnectAvailable => _quickConnectAvailable;
/// The Quick Connect code to show.
String? get code => _code;
JellyfinAccount? get linked => _linked;
bool get busy =>
_step == JellyfinSignInStep.probing || _step == JellyfinSignInStep.signingIn;
void _set(JellyfinSignInStep s, {String? error}) {
_step = s;
_error = error;
if (!_disposed) notifyListeners();
}
/// Checks [input] is a Jellyfin server and whether Quick Connect is on.
Future<void> probe(String input) async {
final url = normalizeServerUrl(input);
if (url == null) {
_set(JellyfinSignInStep.address, error: 'Enter the server address, e.g. jellyfin.example.com');
return;
}
final gen = ++_gen;
_client?.close();
final client = _client = accounts.anonymousClient(url);
_set(JellyfinSignInStep.probing);
try {
final info = await client.publicInfo();
if (gen != _gen) return;
if (info.id.isEmpty) throw const JellyfinException('not a Jellyfin server');
if (reconnect != null && info.id != reconnect!.serverId) {
_set(JellyfinSignInStep.address,
error: 'That address is a different Jellyfin server (${info.name}).');
return;
}
var qc = false;
try {
qc = await client.quickConnectEnabled();
} catch (_) {}
if (gen != _gen) return;
_server = info;
_quickConnectAvailable = qc;
_set(JellyfinSignInStep.method);
} catch (e) {
if (gen != _gen) return;
_set(JellyfinSignInStep.address, error: 'No Jellyfin server at $url (${_short(e)})');
}
}
/// Starts Quick Connect: shows the code, polls until approved, links.
Future<void> startQuickConnect() async {
final client = _client;
if (client == null || _server == null) return;
final gen = ++_gen;
_code = null;
_set(JellyfinSignInStep.quickConnect);
try {
final session = await client.quickConnectInitiate();
if (gen != _gen) return;
_code = session.code;
_set(JellyfinSignInStep.quickConnect);
final auth = await client.waitForQuickConnect(session,
interval: pollInterval, isCancelled: () => gen != _gen || _disposed);
if (auth == null || gen != _gen) return;
await _finish(auth);
} on TimeoutException {
if (gen == _gen) _set(JellyfinSignInStep.method, error: 'The code expired. Try again.');
} catch (e) {
if (gen != _gen) return;
final msg = e is JellyfinException && e.isUnauthorized
? 'Quick Connect is turned off on this server. Use your password.'
: e is JellyfinException && e.statusCode == 404
? 'The code expired. Try again.'
: 'Quick Connect failed: ${_short(e)}';
_set(JellyfinSignInStep.method, error: msg);
}
}
/// Stops waiting for the Quick Connect code.
void cancel() {
_gen++;
_code = null;
_set(_server == null ? JellyfinSignInStep.address : JellyfinSignInStep.method);
}
void usePassword() => _set(JellyfinSignInStep.password);
/// Back to the address field.
void changeAddress() {
_gen++;
_server = null;
_code = null;
_set(JellyfinSignInStep.address);
}
Future<void> signIn(String username, String password) async {
final client = _client;
if (client == null) return;
if (username.trim().isEmpty) {
_set(JellyfinSignInStep.password, error: 'Enter your Jellyfin username');
return;
}
final gen = ++_gen;
_set(JellyfinSignInStep.signingIn);
try {
final auth = await client.authenticateByName(username.trim(), password);
if (gen != _gen) return;
await _finish(auth);
} catch (e) {
if (gen != _gen) return;
_set(JellyfinSignInStep.password,
error: e is JellyfinException && e.isUnauthorized
? 'Wrong username or password'
: 'Sign-in failed: ${_short(e)}');
}
}
Future<void> _finish(JellyfinAuthResult auth) async {
final server = _server!;
if (auth.accessToken.isEmpty) throw const JellyfinException('no token in the answer');
_linked = await accounts.link(baseUrl: _client!.baseUrl, server: server, auth: auth);
_code = null;
_set(JellyfinSignInStep.done);
}
static String _short(Object e) {
final s = '$e'.replaceFirst(RegExp(r'^JellyfinException( \d+)?: '), '');
return s.length > 140 ? '${s.substring(0, 140)}…' : s;
}
@override
void dispose() {
_disposed = true;
_gen++;
_client?.close();
super.dispose();
}
}