refactor(jellyfin): share auth and time handling

This commit is contained in:
edde746
2026-07-12 08:42:22 +02:00
parent aa230983d5
commit 65d3d8e471
6 changed files with 240 additions and 105 deletions
+90 -74
View File
@@ -24,6 +24,20 @@ class JellyfinQuickConnectInitiation {
const JellyfinQuickConnectInitiation({required this.code, required this.secret});
}
class _JellyfinAuthenticationResponse {
final String accessToken;
final String userId;
final String userName;
final bool isAdministrator;
const _JellyfinAuthenticationResponse({
required this.accessToken,
required this.userId,
required this.userName,
required this.isAdministrator,
});
}
/// Auth flow for adding or refreshing a [JellyfinConnection].
///
/// Lifecycle for adding a server:
@@ -114,53 +128,28 @@ class JellyfinConnectionAuthService implements ConnectionAuthService {
headers: {'Authorization': authHeader, 'Content-Type': 'application/json'},
);
try {
final response = await client.post(
'/Users/AuthenticateByName',
body: jsonEncode({'Username': username, 'Pw': password}),
// Bound the auth POST so a hanging server can't freeze the auth
// screen indefinitely; mirrors the timeout on [probe].
timeout: MediaServerTimeouts.jellyfinProbe,
final auth = await _readAuthenticationResponse(
client.post(
'/Users/AuthenticateByName',
body: jsonEncode({'Username': username, 'Pw': password}),
timeout: MediaServerTimeouts.jellyfinProbe,
),
rejectedStatusCodes: const {401, 403},
rejectionMessage: 'Invalid username or password',
responseLabel: 'Authentication response',
notJsonMessage: 'Authentication response was not JSON',
);
if (response.statusCode == 401 || response.statusCode == 403) {
throw MediaServerAuthException('Invalid username or password', statusCode: response.statusCode);
}
throwIfHttpError(response);
final data = response.data;
if (data is! Map<String, dynamic>) {
throw MediaServerAuthException('Authentication response was not JSON');
}
final accessToken = data['AccessToken'] as String?;
final user = data['User'] as Map<String, dynamic>?;
if (accessToken == null || user == null) {
throw MediaServerAuthException('Authentication response missing AccessToken or User');
}
final userId = user['Id'] as String?;
final userName = user['Name'] as String?;
if (userId == null || userName == null) {
throw MediaServerAuthException('Authentication response missing User.Id or User.Name');
}
final policy = user['Policy'] as Map<String, dynamic>?;
final isAdmin = policy?['IsAdministrator'] as bool? ?? false;
return _buildConnection(
info: info,
normalisedBaseUrl: normalised,
baseUrls: baseUrls,
userId: userId,
userName: userName,
accessToken: accessToken,
userId: auth.userId,
userName: auth.userName,
accessToken: auth.accessToken,
deviceId: deviceId,
isAdministrator: isAdmin,
isAdministrator: auth.isAdministrator,
);
} on TimeoutException {
// Defensive: most request timeouts are wrapped by MediaServerHttpClient.
// Surface raw timeouts as a URL-level error if one escapes.
throw MediaServerUrlException('Server did not respond in time');
} on MediaServerHttpException catch (e) {
if (e.statusCode == 401 || e.statusCode == 403) {
throw MediaServerAuthException('Invalid username or password', statusCode: e.statusCode);
}
rethrow;
} finally {
client.close();
}
@@ -304,49 +293,28 @@ class JellyfinConnectionAuthService implements ConnectionAuthService {
headers: {'Authorization': authHeader, 'Content-Type': 'application/json'},
);
try {
final response = await exchangeClient.post(
'/Users/AuthenticateWithQuickConnect',
body: jsonEncode({'Secret': secret}),
final auth = await _readAuthenticationResponse(
exchangeClient.post(
'/Users/AuthenticateWithQuickConnect',
body: jsonEncode({'Secret': secret}),
timeout: MediaServerTimeouts.jellyfinProbe,
),
rejectedStatusCodes: const {400, 401, 403},
rejectionMessage: 'Quick Connect exchange rejected by server',
responseLabel: 'Quick Connect exchange',
notJsonMessage: 'Quick Connect exchange response was not JSON',
);
if (response.statusCode == 400) {
throw MediaServerAuthException('Quick Connect exchange rejected by server', statusCode: response.statusCode);
}
if (response.statusCode == 401 || response.statusCode == 403) {
throw MediaServerAuthException('Quick Connect exchange rejected by server', statusCode: response.statusCode);
}
throwIfHttpError(response);
final data = response.data;
if (data is! Map<String, dynamic>) {
throw MediaServerAuthException('Quick Connect exchange response was not JSON');
}
final accessToken = data['AccessToken'] as String?;
final user = data['User'] as Map<String, dynamic>?;
if (accessToken == null || user == null) {
throw MediaServerAuthException('Quick Connect exchange missing AccessToken or User');
}
final userId = user['Id'] as String?;
final userName = user['Name'] as String?;
if (userId == null || userName == null) {
throw MediaServerAuthException('Quick Connect exchange missing User.Id or User.Name');
}
final policy = user['Policy'] as Map<String, dynamic>?;
final isAdmin = policy?['IsAdministrator'] as bool? ?? false;
return _buildConnection(
info: info,
normalisedBaseUrl: normalised,
baseUrls: baseUrls,
userId: userId,
userName: userName,
accessToken: accessToken,
userId: auth.userId,
userName: auth.userName,
accessToken: auth.accessToken,
deviceId: deviceId,
isAdministrator: isAdmin,
isAdministrator: auth.isAdministrator,
);
} on MediaServerHttpException catch (e) {
if (e.statusCode == 400 || e.statusCode == 401 || e.statusCode == 403) {
throw MediaServerAuthException('Quick Connect exchange rejected by server', statusCode: e.statusCode);
}
rethrow;
} finally {
exchangeClient.close();
}
@@ -412,6 +380,54 @@ class JellyfinConnectionAuthService implements ConnectionAuthService {
/// produce double slashes. Delegates to the shared [stripTrailingSlash].
static String _normaliseBaseUrl(String input) => JellyfinEndpointDiscovery.normalizeBaseUrl(input);
static Future<_JellyfinAuthenticationResponse> _readAuthenticationResponse(
Future<MediaServerResponse> responseFuture, {
required Set<int> rejectedStatusCodes,
required String rejectionMessage,
required String responseLabel,
required String notJsonMessage,
}) async {
try {
final response = await responseFuture;
if (rejectedStatusCodes.contains(response.statusCode)) {
throw MediaServerAuthException(rejectionMessage, statusCode: response.statusCode);
}
throwIfHttpError(response);
final data = response.data;
if (data is! Map<String, dynamic>) {
throw MediaServerAuthException(notJsonMessage);
}
final accessToken = data['AccessToken'] as String?;
final user = data['User'] as Map<String, dynamic>?;
if (accessToken == null || user == null) {
throw MediaServerAuthException('$responseLabel missing AccessToken or User');
}
final userId = user['Id'] as String?;
final userName = user['Name'] as String?;
if (userId == null || userName == null) {
throw MediaServerAuthException('$responseLabel missing User.Id or User.Name');
}
final policy = user['Policy'] as Map<String, dynamic>?;
return _JellyfinAuthenticationResponse(
accessToken: accessToken,
userId: userId,
userName: userName,
isAdministrator: policy?['IsAdministrator'] as bool? ?? false,
);
} on TimeoutException {
// MediaServerHttpClient normally wraps timeouts, but keep raw client
// implementations aligned with the same auth policy.
throw MediaServerUrlException('Server did not respond in time');
} on MediaServerHttpException catch (e) {
final status = e.statusCode;
if (status != null && rejectedStatusCodes.contains(status)) {
throw MediaServerAuthException(rejectionMessage, statusCode: status);
}
rethrow;
}
}
/// Build a [JellyfinConnection] from a successful auth/exchange response.
/// Connection id is derived from `(machineId, userId)` so each user on a
/// given server has a single stable connection row.
@@ -73,11 +73,6 @@ mixin _JellyfinLiveTvMethods on MediaServerCacheMixin {
LiveTvProgram _programFromJson(Map<String, dynamic> json) {
final id = json['Id'] as String?;
int? toEpochSec(dynamic raw) {
if (raw is! String || raw.isEmpty) return null;
final ms = DateTime.tryParse(raw)?.toUtc().millisecondsSinceEpoch;
return ms != null ? ms ~/ 1000 : null;
}
final tags = json['ImageTags'];
String? primaryTag;
@@ -95,8 +90,8 @@ mixin _JellyfinLiveTvMethods on MediaServerCacheMixin {
summary: json['Overview'] as String?,
type: 'episode',
year: (json['ProductionYear'] as num?)?.toInt(),
beginsAt: toEpochSec(json['StartDate']),
endsAt: toEpochSec(json['EndDate']),
beginsAt: jellyfinIsoToEpochSeconds(json['StartDate'] as String?),
endsAt: jellyfinIsoToEpochSeconds(json['EndDate'] as String?),
grandparentTitle: json['SeriesName'] as String?,
parentTitle: json['SeasonName'] as String?,
index: (json['IndexNumber'] as num?)?.toInt(),
@@ -239,8 +239,8 @@ mixin _JellyfinPlaylistMethods on MediaServerCacheMixin {
smart: false,
playlistType: (json['MediaType'] as String?)?.toLowerCase() ?? 'video',
leafCount: json['ChildCount'] as int?,
addedAt: _epochSecondsFromJson(json['DateCreated'] as String?),
updatedAt: _epochSecondsFromJson(json['DateLastSaved'] as String?),
addedAt: jellyfinIsoToEpochSeconds(json['DateCreated'] as String?),
updatedAt: jellyfinIsoToEpochSeconds(json['DateLastSaved'] as String?),
thumbPath: _absolutizeImagePath(_imageTagPath(id, json['ImageTags'])),
serverId: serverId,
serverName: serverName,
@@ -259,12 +259,6 @@ mixin _JellyfinPlaylistMethods on MediaServerCacheMixin {
return true;
}
int? _epochSecondsFromJson(String? iso) {
if (iso == null || iso.isEmpty) return null;
final dt = DateTime.tryParse(iso);
return dt == null ? null : dt.millisecondsSinceEpoch ~/ 1000;
}
String? _imageTagPath(String id, Object? tags) {
if (tags is! Map<String, dynamic>) return null;
final tag = tags['Primary'];
+11 -15
View File
@@ -70,11 +70,7 @@ class JellyfinSequentialLauncher extends MediaListPlaybackLauncher {
execute: (dismissLoading) async {
final client = clientForTesting ?? _resolveClient(ServerId(serverId));
if (client == null) {
await dismissLoading();
if (context.mounted) {
showErrorSnackBar(context, t.errors.noClientAvailable);
}
return PlayQueueError(Exception('No client for server $serverId'));
return _missingClientError(serverId, dismissLoading);
}
// Playlists go through the dedicated `/Playlists/{id}/Items` endpoint
@@ -145,11 +141,7 @@ class JellyfinSequentialLauncher extends MediaListPlaybackLauncher {
execute: (dismissLoading) async {
final client = clientForTesting ?? _resolveClient(ServerId(serverId));
if (client == null) {
await dismissLoading();
if (context.mounted) {
showErrorSnackBar(context, t.errors.noClientAvailable);
}
return PlayQueueError(Exception('No client for server $serverId'));
return _missingClientError(serverId, dismissLoading);
}
final fetched = client is JellyfinClient
@@ -221,11 +213,7 @@ class JellyfinSequentialLauncher extends MediaListPlaybackLauncher {
execute: (dismissLoading) async {
final client = clientForTesting ?? _resolveClient(ServerId(serverId));
if (client == null) {
await dismissLoading();
if (context.mounted) {
showErrorSnackBar(context, t.errors.noClientAvailable);
}
return PlayQueueError(Exception('No client for server $serverId'));
return _missingClientError(serverId, dismissLoading);
}
final raw = await client.fetchClientSideEpisodeQueue(seriesId);
@@ -266,4 +254,12 @@ class JellyfinSequentialLauncher extends MediaListPlaybackLauncher {
final provider = Provider.of<MultiServerProvider>(context, listen: false);
return provider.serverManager.getClient(serverId);
}
Future<PlayQueueError> _missingClientError(String serverId, Future<void> Function() dismissLoading) async {
await dismissLoading();
if (context.mounted) {
showErrorSnackBar(context, t.errors.noClientAvailable);
}
return PlayQueueError(Exception('No client for server $serverId'));
}
}
+105 -1
View File
@@ -1,15 +1,19 @@
import 'dart:async';
import 'dart:convert';
import 'package:fake_async/fake_async.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:plezy/connection/connection.dart';
import 'package:plezy/exceptions/media_server_exceptions.dart';
import 'package:plezy/services/jellyfin_auth_service.dart';
import 'package:plezy/services/jellyfin_endpoint_discovery.dart';
import 'package:plezy/utils/log_redaction_manager.dart';
import 'package:plezy/utils/media_server_timeouts.dart';
/// Helpers for stubbing http responses keyed by request path.
typedef _Handler = http.Response Function(http.BaseRequest req);
typedef _Handler = FutureOr<http.Response> Function(http.BaseRequest req);
http.Response _ok(Object json) => http.Response(jsonEncode(json), 200, headers: {'content-type': 'application/json'});
http.Response _bareOk(String body) => http.Response(body, 200, headers: {'content-type': 'application/json'});
@@ -37,6 +41,17 @@ JellyfinConnectionAuthService _service({required _Handler handler}) {
);
}
Future<Object> _captureError(Future<dynamic> future) async {
try {
await future;
} catch (error) {
return error;
}
throw StateError('Expected future to fail');
}
const _serverInfo = JellyfinServerInfo(serverName: 'Home', machineId: 'srv-1', version: '10.9.0');
void main() {
setUp(LogRedactionManager.clearTrackedValues);
tearDown(LogRedactionManager.clearTrackedValues);
@@ -451,6 +466,95 @@ void main() {
});
});
group('Jellyfin authentication response parity', () {
test('password and Quick Connect exchange use the same timeout', () {
fakeAsync((async) {
final passwordResponse = Completer<http.Response>();
final quickConnectResponse = Completer<http.Response>();
final passwordService = _service(handler: (_) => passwordResponse.future);
final quickConnectService = _service(
handler: (req) {
if (req.url.path == '/QuickConnect/Connect') return _ok({'Authenticated': true});
return quickConnectResponse.future;
},
);
Object? passwordError;
Object? quickConnectError;
unawaited(
_captureError(
passwordService.authenticateByName(
baseUrl: 'https://jf.example.com',
username: 'edde',
password: 'pw',
deviceId: 'dev-xyz',
serverInfo: _serverInfo,
),
).then((error) => passwordError = error),
);
unawaited(
_captureError(
quickConnectService.authenticateByQuickConnect(
baseUrl: 'https://jf.example.com',
secret: 'sec',
deviceId: 'dev-xyz',
serverInfo: _serverInfo,
),
).then((error) => quickConnectError = error),
);
async.flushMicrotasks();
expect(passwordError, isNull);
expect(quickConnectError, isNull);
async.elapse(MediaServerTimeouts.jellyfinProbe + const Duration(milliseconds: 1));
async.flushMicrotasks();
for (final error in [passwordError, quickConnectError]) {
expect(
error,
isA<MediaServerHttpException>().having(
(exception) => exception.type,
'type',
MediaServerHttpErrorType.connectionTimeout,
),
);
}
});
});
test('password and Quick Connect exchange preserve non-auth HTTP errors', () async {
final passwordService = _service(handler: (_) => _status(500));
final quickConnectService = _service(
handler: (req) => req.url.path == '/QuickConnect/Connect' ? _ok({'Authenticated': true}) : _status(500),
);
final errors = [
await _captureError(
passwordService.authenticateByName(
baseUrl: 'https://jf.example.com',
username: 'edde',
password: 'pw',
deviceId: 'dev-xyz',
serverInfo: _serverInfo,
),
),
await _captureError(
quickConnectService.authenticateByQuickConnect(
baseUrl: 'https://jf.example.com',
secret: 'sec',
deviceId: 'dev-xyz',
serverInfo: _serverInfo,
),
),
];
for (final error in errors) {
expect(error, isA<MediaServerHttpException>().having((exception) => exception.statusCode, 'statusCode', 500));
}
});
});
group('JellyfinConnectionAuthService.validate', () {
test('returns true when /Users/Me responds 200', () async {
final svc = _service(
+30
View File
@@ -0,0 +1,30 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/utils/jellyfin_time.dart';
void main() {
group('Jellyfin time conversions', () {
test('converts ticks and milliseconds in both directions', () {
expect(jellyfinTicksToMs(12_345_678), 1234);
expect(jellyfinTicksToMs(12.5), 0);
expect(jellyfinTicksToMs('10000'), isNull);
expect(msToJellyfinTicks(1234), 12_340_000);
});
test('converts ISO timestamps to UTC epoch seconds', () {
expect(jellyfinIsoToEpochSeconds('1970-01-01T00:00:01.999Z'), 1);
expect(jellyfinIsoToEpochSeconds('1970-01-01T01:00:01+01:00'), 1);
});
test('returns null for missing or invalid ISO timestamps', () {
expect(jellyfinIsoToEpochSeconds(null), isNull);
expect(jellyfinIsoToEpochSeconds(''), isNull);
expect(jellyfinIsoToEpochSeconds('not-a-date'), isNull);
});
test('truncates ISO timestamps to the calendar date', () {
expect(jellyfinIsoToYmd('2026-07-12T09:30:00Z'), '2026-07-12');
expect(jellyfinIsoToYmd('2026-07-12'), '2026-07-12');
expect(jellyfinIsoToYmd(''), isNull);
});
});
}