Non-English users saw English text in a dozen places and blank labels in sixteen more. The English came from sites that produce their copy away from the widget that renders it, which is what the structural hardcoded-string check cannot see: picture-in-picture refused with a raw literal instead of the pipErrors.notSupported key that already existed; the two Jellyfin/Emby auth throws missing display: rendered their developer message on the add-server form; ServerParsingException.toString() fed its English into the localized "Failed to load servers" wrapper; Watch Together interpolated the whole PeerError, so a failed create read "Failed to create session: PeerError(PeerErrorType.timeout): Timed out creating session" and join printed its prefix twice; the hub and playlist continuation footers rendered exception.toString(); shader rows showed an English title over an already translated subtitle; the player queue fell back to the raw Dart enum name; a Plex home user with no title showed "Unknown"; a failed player start showed "Exception: Failed to initialize player"; and the tvOS top-shelf header was hardcoded in an extension that has no Flutter engine. The blanks came from three recent features that added English keys without translations. clean_translations.py filled all 21 siblings with empty strings, so the Android TV resolution switch, every Jellyfin/Emby recording-rule field, the demuxer row, the Companion Remote address caption and the Seerr blocklist pill rendered nothing at all. Two fixes are structural rather than key swaps. ContinuationStatusSliver now takes an errorContext and calls a new non-logging localizedLoadErrorText, so no future throw can leak through it. lib/mpv stays free of user-facing copy: it raises a PlayerInitializationException sentinel and a PlayerError.playerInitFailed cause tag that the player screen resolves to localized text. The tvOS section title travels in the shelf payload, additively, so an older cache still renders.
461 lines
18 KiB
Dart
461 lines
18 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
|
|
import 'package:flutter/foundation.dart' show visibleForTesting;
|
|
import 'package:http/http.dart' as http;
|
|
|
|
import '../connection/connection.dart';
|
|
import '../exceptions/media_server_exceptions.dart';
|
|
import '../i18n/strings.g.dart';
|
|
import '../media/media_browser_dialect.dart';
|
|
import '../utils/media_server_http_client.dart';
|
|
import '../utils/media_server_timeouts.dart';
|
|
import '../utils/log_redaction_manager.dart';
|
|
import '../utils/poll_with_backoff.dart';
|
|
import 'jellyfin_auth_header.dart';
|
|
import 'jellyfin_endpoint_discovery.dart';
|
|
|
|
/// Result of `POST /QuickConnect/Initiate`. The [code] is shown to the user
|
|
/// and entered in their Jellyfin web UI to approve sign-in; the [secret] is
|
|
/// the opaque polling/exchange handle.
|
|
class JellyfinQuickConnectInitiation {
|
|
final String code;
|
|
final String secret;
|
|
const JellyfinQuickConnectInitiation({required this.code, required this.secret});
|
|
}
|
|
|
|
class _JellyfinAuthenticationResponse {
|
|
final String accessToken;
|
|
final String userId;
|
|
final String userName;
|
|
final bool isAdministrator;
|
|
final String? primaryImageTag;
|
|
|
|
const _JellyfinAuthenticationResponse({
|
|
required this.accessToken,
|
|
required this.userId,
|
|
required this.userName,
|
|
required this.isAdministrator,
|
|
this.primaryImageTag,
|
|
});
|
|
}
|
|
|
|
/// Auth flow for adding a [JellyfinConnection].
|
|
///
|
|
/// Lifecycle for adding a server:
|
|
/// 1. [probe] — validates the URL responds as a MediaBrowser server.
|
|
/// 2. [authenticateByName] (or [authenticateByQuickConnect]) — exchanges
|
|
/// credentials for a long-lived access token and returns a built
|
|
/// [JellyfinConnection] ready to insert into [ConnectionRegistry].
|
|
class JellyfinConnectionAuthService {
|
|
JellyfinConnectionAuthService({
|
|
required this.clientName,
|
|
required this.clientVersion,
|
|
required this.deviceName,
|
|
MediaBrowserDialect dialect = MediaBrowserDialect.jellyfin,
|
|
@visibleForTesting this._testHttpClientFactory,
|
|
}) : dialect = dialect,
|
|
_endpointDiscovery = JellyfinEndpointDiscovery(dialect: dialect, testHttpClientFactory: _testHttpClientFactory);
|
|
|
|
/// App identity sent in the `MediaBrowser` Authorization header. Jellyfin
|
|
/// and Emby use `Client`/`Device`/`DeviceId`/`Version` to populate the
|
|
/// device list in their admin UI and to issue tokens.
|
|
final MediaBrowserDialect dialect;
|
|
final String clientName;
|
|
final String clientVersion;
|
|
final String deviceName;
|
|
|
|
/// Test-only HTTP client factory. When non-null, every internal
|
|
/// [MediaServerHttpClient] is built with a fresh client from this factory
|
|
/// instead of the platform default — lets unit tests intercept requests
|
|
/// via `package:http/testing`'s [http.MockClient]. Returns a factory rather
|
|
/// than a single instance because each [MediaServerHttpClient] closes its
|
|
/// underlying client on `close()`.
|
|
final http.Client Function()? _testHttpClientFactory;
|
|
|
|
final JellyfinEndpointDiscovery _endpointDiscovery;
|
|
|
|
MediaServerHttpClient _buildHttpClient({required String baseUrl, Map<String, String> headers = const {}}) {
|
|
LogRedactionManager.registerServerUrl(baseUrl);
|
|
return MediaServerHttpClient(baseUrl: baseUrl, defaultHeaders: headers, client: _testHttpClientFactory?.call());
|
|
}
|
|
|
|
/// Probe the server identified by [baseUrl] without authenticating. Returns
|
|
/// the public info used by the UI to confirm "yes that's the right server"
|
|
/// before asking for credentials. Throws [MediaServerUrlException] when the
|
|
/// URL is unreachable or doesn't look like the selected media server.
|
|
Future<JellyfinServerInfo> probe(String baseUrl) async {
|
|
return _endpointDiscovery.probe(baseUrl);
|
|
}
|
|
|
|
Future<JellyfinEndpointRaceResult> raceEndpoints(
|
|
Iterable<String> baseUrls, {
|
|
String? preferredUrl,
|
|
String? expectedMachineId,
|
|
Iterable<String>? baseUrlsToPersist,
|
|
Iterable<String>? baseUrlsToValidate,
|
|
Iterable<Iterable<String>>? baseUrlValidationGroups,
|
|
}) {
|
|
return _endpointDiscovery.raceEndpoints(
|
|
baseUrls,
|
|
preferredUrl: preferredUrl,
|
|
expectedMachineId: expectedMachineId,
|
|
baseUrlsToPersist: baseUrlsToPersist,
|
|
baseUrlsToValidate: baseUrlsToValidate,
|
|
baseUrlValidationGroups: baseUrlValidationGroups,
|
|
);
|
|
}
|
|
|
|
/// Authenticate against [baseUrl] with [username]/[password] and return a
|
|
/// fully-formed [JellyfinConnection]. Throws [MediaServerAuthException] for
|
|
/// 401/403 responses; other transport errors propagate.
|
|
Future<JellyfinConnection> authenticateByName({
|
|
required String baseUrl,
|
|
List<String>? baseUrls,
|
|
required String username,
|
|
required String password,
|
|
required String deviceId,
|
|
JellyfinServerInfo? serverInfo,
|
|
}) async {
|
|
final validDeviceId = requireJellyfinDeviceId(deviceId);
|
|
final normalised = _normaliseBaseUrl(baseUrl);
|
|
final info = serverInfo ?? await probe(normalised);
|
|
|
|
final authHeader = buildJellyfinAuthHeader(
|
|
clientName: clientName,
|
|
clientVersion: clientVersion,
|
|
deviceName: deviceName,
|
|
deviceId: validDeviceId,
|
|
);
|
|
final client = _buildHttpClient(
|
|
baseUrl: normalised,
|
|
headers: {'Authorization': authHeader, 'Content-Type': 'application/json'},
|
|
);
|
|
try {
|
|
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',
|
|
rejectionDisplay: t.addServer.invalidCredentials,
|
|
responseLabel: 'Authentication response',
|
|
notJsonMessage: 'Authentication response was not JSON',
|
|
notJsonDisplay: t.addServer.authResponseNotJson,
|
|
);
|
|
|
|
return _buildConnection(
|
|
info: info,
|
|
normalisedBaseUrl: normalised,
|
|
baseUrls: baseUrls,
|
|
userId: auth.userId,
|
|
userName: auth.userName,
|
|
accessToken: auth.accessToken,
|
|
deviceId: validDeviceId,
|
|
isAdministrator: auth.isAdministrator,
|
|
primaryImageTag: auth.primaryImageTag,
|
|
);
|
|
} finally {
|
|
client.close();
|
|
}
|
|
}
|
|
|
|
/// Whether [baseUrl] has Quick Connect enabled. Returns `false` without a
|
|
/// request for dialects that do not support it, and for any probe failure —
|
|
/// Jellyfin <10.7 returns 404 on this path, and an offline server is
|
|
/// functionally indistinguishable from QC-disabled for UI purposes.
|
|
Future<bool> isQuickConnectEnabled(String baseUrl) async {
|
|
if (!dialect.supportsQuickConnect) return false;
|
|
final normalised = _normaliseBaseUrl(baseUrl);
|
|
final client = _buildHttpClient(baseUrl: normalised);
|
|
try {
|
|
final response = await client.get('/QuickConnect/Enabled', timeout: MediaServerTimeouts.jellyfinProbe);
|
|
if (response.statusCode != 200) return false;
|
|
final data = response.data;
|
|
// The endpoint returns a bare JSON `true`/`false`, not an object.
|
|
return data is bool ? data : false;
|
|
} catch (_) {
|
|
return false;
|
|
} finally {
|
|
client.close();
|
|
}
|
|
}
|
|
|
|
/// Initiate a Quick Connect session: returns the user-facing code and the
|
|
/// polling secret. The Authorization header carries the device identity
|
|
/// only — there's no token until the secret is exchanged after approval.
|
|
Future<JellyfinQuickConnectInitiation> initiateQuickConnect({
|
|
required String baseUrl,
|
|
required String deviceId,
|
|
}) async {
|
|
_requireQuickConnectSupport();
|
|
final validDeviceId = requireJellyfinDeviceId(deviceId);
|
|
final normalised = _normaliseBaseUrl(baseUrl);
|
|
final authHeader = buildJellyfinAuthHeader(
|
|
clientName: clientName,
|
|
clientVersion: clientVersion,
|
|
deviceName: deviceName,
|
|
deviceId: validDeviceId,
|
|
);
|
|
final client = _buildHttpClient(baseUrl: normalised, headers: {'Authorization': authHeader});
|
|
try {
|
|
// Current Jellyfin (10.7+) accepts GET; older builds required POST.
|
|
// Try GET first, fall back on 405.
|
|
var response = await client.get('/QuickConnect/Initiate', timeout: MediaServerTimeouts.jellyfinProbe);
|
|
if (response.statusCode == 405) {
|
|
response = await client.post('/QuickConnect/Initiate', timeout: MediaServerTimeouts.jellyfinProbe);
|
|
}
|
|
if (response.statusCode == 401 || response.statusCode == 403) {
|
|
throw MediaServerAuthException(
|
|
'Quick Connect rejected by server',
|
|
statusCode: response.statusCode,
|
|
display: t.addServer.quickConnectRejected,
|
|
);
|
|
}
|
|
throwIfHttpError(response);
|
|
final data = response.data;
|
|
if (data is! Map<String, dynamic>) {
|
|
throw MediaServerAuthException('Quick Connect response was not JSON', display: t.addServer.quickConnectNotJson);
|
|
}
|
|
final code = data['Code'] as String?;
|
|
final secret = data['Secret'] as String?;
|
|
if (code == null || secret == null) {
|
|
throw MediaServerAuthException(
|
|
'Quick Connect response missing Code or Secret',
|
|
display: t.addServer.quickConnectMissingFields,
|
|
);
|
|
}
|
|
return JellyfinQuickConnectInitiation(code: code, secret: secret);
|
|
} on MediaServerHttpException catch (e) {
|
|
if (e.statusCode == 401 || e.statusCode == 403) {
|
|
throw MediaServerAuthException(
|
|
'Quick Connect rejected by server',
|
|
statusCode: e.statusCode,
|
|
display: t.addServer.quickConnectRejected,
|
|
);
|
|
}
|
|
rethrow;
|
|
} finally {
|
|
client.close();
|
|
}
|
|
}
|
|
|
|
/// Poll `/QuickConnect/Connect?secret=…` until the user approves the code
|
|
/// in their Jellyfin web UI, then exchange the approved secret for a token
|
|
/// and return a fully-formed [JellyfinConnection]. Returns `null` on
|
|
/// cancel, timeout, or server-side secret expiry (404 mid-poll). Throws
|
|
/// [MediaServerAuthException] on auth failures or an unsupported dialect.
|
|
Future<JellyfinConnection?> authenticateByQuickConnect({
|
|
required String baseUrl,
|
|
List<String>? baseUrls,
|
|
required String secret,
|
|
required String deviceId,
|
|
JellyfinServerInfo? serverInfo,
|
|
Duration timeout = const Duration(minutes: 5),
|
|
bool Function()? shouldCancel,
|
|
}) async {
|
|
_requireQuickConnectSupport();
|
|
final validDeviceId = requireJellyfinDeviceId(deviceId);
|
|
final normalised = _normaliseBaseUrl(baseUrl);
|
|
final info = serverInfo ?? await probe(normalised);
|
|
LogRedactionManager.registerCustomValue(secret);
|
|
|
|
final authHeader = buildJellyfinAuthHeader(
|
|
clientName: clientName,
|
|
clientVersion: clientVersion,
|
|
deviceName: deviceName,
|
|
deviceId: validDeviceId,
|
|
);
|
|
// Reuse a single client across the polling loop — opening one per tick
|
|
// would churn TCP connections needlessly on a 5-minute window.
|
|
final pollClient = _buildHttpClient(baseUrl: normalised, headers: {'Authorization': authHeader});
|
|
bool? approved;
|
|
try {
|
|
approved = await pollWithBackoff<bool>(
|
|
endTime: DateTime.now().add(timeout),
|
|
shouldCancel: shouldCancel,
|
|
probe: () async {
|
|
try {
|
|
final response = await pollClient.get(
|
|
'/QuickConnect/Connect',
|
|
queryParameters: {'secret': secret},
|
|
timeout: MediaServerTimeouts.jellyfinProbe,
|
|
);
|
|
// 404 mid-poll = secret expired or revoked server-side. Terminal.
|
|
if (response.statusCode == 404) throw const PollTerminatedSignal();
|
|
if (response.statusCode == 401 || response.statusCode == 403) {
|
|
throw MediaServerAuthException(
|
|
'Quick Connect poll rejected by server',
|
|
statusCode: response.statusCode,
|
|
display: t.addServer.quickConnectPollRejected,
|
|
);
|
|
}
|
|
throwIfHttpError(response);
|
|
final data = response.data;
|
|
if (data is Map<String, dynamic> && data['Authenticated'] == true) {
|
|
return true;
|
|
}
|
|
return null;
|
|
} on MediaServerHttpException catch (e) {
|
|
if (e.statusCode == 404) throw const PollTerminatedSignal();
|
|
if (e.statusCode == 401 || e.statusCode == 403) {
|
|
throw MediaServerAuthException(
|
|
'Quick Connect poll rejected by server',
|
|
statusCode: e.statusCode,
|
|
display: t.addServer.quickConnectPollRejected,
|
|
);
|
|
}
|
|
// Transient network blip — let the backoff handle it. The outer
|
|
// timeout is the safety net if the server is durably broken.
|
|
return null;
|
|
}
|
|
},
|
|
);
|
|
} finally {
|
|
pollClient.close();
|
|
}
|
|
|
|
if (approved != true) return null;
|
|
|
|
// Exchange the approved secret for an access token.
|
|
final exchangeClient = _buildHttpClient(
|
|
baseUrl: normalised,
|
|
headers: {'Authorization': authHeader, 'Content-Type': 'application/json'},
|
|
);
|
|
try {
|
|
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',
|
|
rejectionDisplay: t.addServer.quickConnectRejected,
|
|
responseLabel: 'Quick Connect exchange',
|
|
notJsonMessage: 'Quick Connect exchange response was not JSON',
|
|
notJsonDisplay: t.addServer.quickConnectNotJson,
|
|
);
|
|
|
|
return _buildConnection(
|
|
info: info,
|
|
normalisedBaseUrl: normalised,
|
|
baseUrls: baseUrls,
|
|
userId: auth.userId,
|
|
userName: auth.userName,
|
|
accessToken: auth.accessToken,
|
|
deviceId: validDeviceId,
|
|
isAdministrator: auth.isAdministrator,
|
|
primaryImageTag: auth.primaryImageTag,
|
|
);
|
|
} finally {
|
|
exchangeClient.close();
|
|
}
|
|
}
|
|
|
|
/// Emby 4.9.5 returns 404 for every `/QuickConnect/*` route. Emby Connect is
|
|
/// a separate account-level product and is not an authentication flow Plezy
|
|
/// implements.
|
|
void _requireQuickConnectSupport() {
|
|
if (!dialect.supportsQuickConnect) {
|
|
throw MediaServerAuthException('Quick Connect rejected by server', display: t.addServer.quickConnectRejected);
|
|
}
|
|
}
|
|
|
|
/// Strip any trailing slash so subsequent path joins (`/Users/...`) don't
|
|
/// 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 rejectionDisplay,
|
|
required String responseLabel,
|
|
required String notJsonMessage,
|
|
required String notJsonDisplay,
|
|
}) async {
|
|
try {
|
|
final response = await responseFuture;
|
|
if (rejectedStatusCodes.contains(response.statusCode)) {
|
|
throw MediaServerAuthException(rejectionMessage, statusCode: response.statusCode, display: rejectionDisplay);
|
|
}
|
|
throwIfHttpError(response);
|
|
|
|
final data = response.data;
|
|
if (data is! Map<String, dynamic>) {
|
|
throw MediaServerAuthException(notJsonMessage, display: notJsonDisplay);
|
|
}
|
|
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',
|
|
display: t.addServer.authResponseIncomplete,
|
|
);
|
|
}
|
|
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',
|
|
display: t.addServer.authResponseIncomplete,
|
|
);
|
|
}
|
|
final policy = user['Policy'] as Map<String, dynamic>?;
|
|
return _JellyfinAuthenticationResponse(
|
|
accessToken: accessToken,
|
|
userId: userId,
|
|
userName: userName,
|
|
isAdministrator: policy?['IsAdministrator'] as bool? ?? false,
|
|
primaryImageTag: JellyfinConnection.readPrimaryImageTag(user),
|
|
);
|
|
} 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', display: t.addServer.serverTimedOut);
|
|
} on MediaServerHttpException catch (e) {
|
|
final status = e.statusCode;
|
|
if (status != null && rejectedStatusCodes.contains(status)) {
|
|
throw MediaServerAuthException(rejectionMessage, statusCode: status, display: rejectionDisplay);
|
|
}
|
|
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.
|
|
JellyfinConnection _buildConnection({
|
|
required JellyfinServerInfo info,
|
|
required String normalisedBaseUrl,
|
|
List<String>? baseUrls,
|
|
required String userId,
|
|
required String userName,
|
|
required String accessToken,
|
|
required String deviceId,
|
|
required bool isAdministrator,
|
|
required String? primaryImageTag,
|
|
}) {
|
|
final now = DateTime.now();
|
|
return JellyfinConnection(
|
|
id: '${info.machineId}/$userId',
|
|
baseUrl: normalisedBaseUrl,
|
|
baseUrls: baseUrls,
|
|
serverName: info.serverName,
|
|
serverMachineId: info.machineId,
|
|
userId: userId,
|
|
userName: userName,
|
|
accessToken: accessToken,
|
|
deviceId: deviceId,
|
|
dialect: info.dialect ?? dialect,
|
|
isAdministrator: isAdministrator,
|
|
primaryImageTag: primaryImageTag,
|
|
createdAt: now,
|
|
lastAuthenticatedAt: now,
|
|
);
|
|
}
|
|
}
|