Files
plezy/lib/services/jellyfin_auth_service.dart
T
edde746 05fd622968 feat(emby): add Emby as a MediaBrowser backend alongside Jellyfin
Emby is Jellyfin's upstream ancestor and speaks a near-identical MediaBrowser
API, so the existing Jellyfin stack is parameterised by a `MediaBrowserDialect`
rather than forked. `JellyfinClient`, its auth service, endpoint discovery, LAN
discovery, and the add/edit connection screens all take the dialect and keep one
implementation; `MediaBackend.emby` and `ConnectionKind.emby` carry it through
the neutral models, the Drift `kind` discriminator, downloads, and caches.

Every divergence below was measured against a live Emby 4.9.5 server, not
inferred from documentation, and each is documented at its capability getter.
Jellyfin's request strings stay byte-identical so nothing about its behaviour
changes.

Routes and auth
- Emby only accepts the pre-10.9 user-scoped item routes (`/Users/{id}/Items/…`,
  `/Users/{id}/PlayedItems/…`, `/Users/{id}/FavoriteItems/…`); the unprefixed
  forms Jellyfin 10.11 added return 404.
- The API is also served under a legacy `/emby` prefix, and both dialects accept
  the token as `X-Emby-Token` or `api_key=`.
- Emby answers only its own LAN discovery datagram ("who is EmbyServer?") and
  ignores Jellyfin's; its default HTTPS port is 8920.
- No `/QuickConnect` route exists, so Quick Connect stays Jellyfin-only.

Row fields Emby withholds
- `ProductionYear`, `OfficialRating`, `PremiereDate` and `DateCreated` are absent
  from list rows unless named in `Fields`, which would otherwise strip the year
  and age-rating badge from every card in the app.
- `UserData.LastPlayedDate` never appears on a list row under `Fields=UserData`,
  `EnableUserData=true` or the user-scoped `Ids=` form — only on the single-item
  detail route, or when the Emby-specific `UserDataLastPlayedDate` token is
  requested. Without it every recency-ordered surface silently degrades to
  library-add time, and `JellyfinApiCache.applyWatchState` stamps
  `DateTime.now()` on watched rows, so an offline watch-state pull would rewrite
  the cached play time of everything it walked.

Continue Watching and Next Up
- Emby computes Next Up per series only: the library-wide `/Shows/NextUp` query
  returns nothing under every parameter combination tried. The shelf is
  therefore reconstructed from a played-episode recency scan plus one
  `/Shows/NextUp?SeriesId=` per distinct series, bounded by a shared wall clock
  that covers the scan as well — per-request timeouts cannot bound the pass
  because `MediaServerHttpClient` times the connect and receive phases
  independently. Rows are stamped with their series' newest play from the same
  response that ordered them, so no per-series enrichment request is needed.
- `/Shows/NextUp` ignores `NextUpDateCutoff`, and no server-side played-date
  filter exists to delegate to (`MinDatePlayed` and `MinDateLastPlayed` are
  ignored; `MinDateLastSaved`, `MinDateCreated` and `MinPremiereDate` filter
  unrelated dates), so the 365-day window is applied to the scanned dates.
- The resume route returns items with no saved position, including plain next
  episodes, so the Emby resume leg reads from `/Items?Filters=IsResumable`.
- Emby is ahead of Jellyfin in one place: `/Users/{id}/Items/{id}/HideFromResume`
  makes Continue Watching removal a real capability.

Everything else
- `/Sessions/Playing` and `/Sessions/Playing/Progress` reject a body with no
  `PlaySessionId` (HTTP 400), so playback reporting always sends one.
- Passing any `MediaTypes` value to the playlist query returns an empty list.
- There is no aggregate `/Items/Filters` route; the four filter facets are
  reassembled from `/Genres`, `/OfficialRatings`, `/Studios` and `/Tags`.
- Metadata writes take name-pair lists (`Genres: [{'Name': 'Action'}]`); the
  plain string array is accepted and then silently discarded.
- Custom artwork uploads must be base64 text, not raw bytes — which was broken
  for Jellyfin too and is fixed for both.
- Trickplay, media segments and lyrics 404 on Emby, so scrub previews are absent
  and intro/credit markers fall back to chapter names.

Verified against a local Emby 4.9.5 and a Jellyfin 10.11.11 control server:
onboarding, browse, detail, playable stream URLs serving real bytes, subtitle
sidecars, watch-state write and restore, hubs, cross-server aggregation and
search across both backends simultaneously.
2026-08-05 06:09:26 +02:00

494 lines
19 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 '../media/media_browser_dialect.dart';
import '../utils/app_logger.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';
import 'media_browser_paths.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 or refreshing a [JellyfinConnection].
///
/// Lifecycle for adding a server:
/// 1. [probe] — validates the URL responds as a MediaBrowser server.
/// 2. [authenticateByName] (or future Quick Connect equivalent) — exchanges
/// credentials for a long-lived access token and returns a built
/// [JellyfinConnection] ready to insert into [ConnectionRegistry].
/// 3. (later) [validate] / [refresh] / [signOut] to keep the stored
/// connection current.
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',
responseLabel: 'Authentication response',
notJsonMessage: 'Authentication response was not JSON',
);
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);
}
throwIfHttpError(response);
final data = response.data;
if (data is! Map<String, dynamic>) {
throw MediaServerAuthException('Quick Connect response was not JSON');
}
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');
}
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);
}
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);
}
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);
}
// 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',
responseLabel: 'Quick Connect exchange',
notJsonMessage: 'Quick Connect exchange response was not JSON',
);
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();
}
}
/// Best-effort check that an existing token still works. Returns false on
/// 401/403; throws on transport failures the caller should retry.
Future<bool> validate(Connection connection) async {
if (connection is! JellyfinConnection) return false;
final client = _authenticatedClient(connection);
final currentUser = MediaBrowserPaths(dialect: dialect, userId: connection.userId).currentUser;
try {
final response = await client.get(currentUser, timeout: MediaServerTimeouts.jellyfinProbe);
return response.statusCode == 200;
} on MediaServerHttpException catch (e) {
if (e.statusCode == 401 || e.statusCode == 403) return false;
rethrow;
} finally {
client.close();
}
}
/// Re-check the stored token and return the connection with its status
/// updated accordingly.
Future<Connection> refresh(Connection connection) async {
if (connection is! JellyfinConnection) return connection;
final ok = await validate(connection);
if (!ok) {
return connection.copyWith(status: ConnectionStatus.authError);
}
return connection.copyWith(status: ConnectionStatus.online, lastAuthenticatedAt: DateTime.now());
}
/// Revoke the token server-side and forget local credentials. The caller
/// is responsible for removing the row from [ConnectionRegistry].
Future<void> signOut(Connection connection) async {
if (connection is! JellyfinConnection) return;
final client = _authenticatedClient(connection);
try {
// Best-effort: server may already have invalidated the session.
await client.post('/Sessions/Logout', timeout: MediaServerTimeouts.jellyfinSignOut);
} catch (e) {
appLogger.d('JellyfinConnectionAuthService: signOut best-effort failed: $e');
} finally {
client.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');
}
}
MediaServerHttpClient _authenticatedClient(JellyfinConnection connection) {
LogRedactionManager.registerToken(connection.accessToken);
return _buildHttpClient(
baseUrl: connection.baseUrl,
headers: {
'X-Emby-Token': connection.accessToken,
'Authorization': buildJellyfinAuthHeader(
clientName: clientName,
clientVersion: clientVersion,
deviceName: deviceName,
deviceId: connection.deviceId,
accessToken: connection.accessToken,
),
},
);
}
/// 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 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,
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');
} 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.
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,
status: ConnectionStatus.online,
createdAt: now,
lastAuthenticatedAt: now,
);
}
}