Files
plezy/lib/services/jellyfin_client.dart
T

489 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 'package:package_info_plus/package_info_plus.dart';
import '../connection/connection.dart';
import '../media/library_filter_result.dart';
import '../media/library_first_character.dart';
import '../media/library_query.dart';
import 'favorite_channels_repository.dart';
import 'file_info_parser.dart';
import 'library_query_translator.dart';
import '../media/media_filter.dart';
import '../media/live_tv_support.dart';
import '../media/media_backend.dart';
import '../media/media_file_info.dart';
import '../media/media_hub.dart';
import '../media/media_item.dart';
import '../media/media_kind.dart';
import '../media/media_library.dart';
import '../media/media_playlist.dart';
import '../media/media_server_client.dart';
import '../media/playback_report_metadata.dart';
import '../media/server_capabilities.dart';
import '../models/jellyfin/jellyfin_user_profile.dart';
import '../models/livetv_channel.dart';
import '../models/livetv_dvr.dart';
import '../models/livetv_lineup.dart';
import '../models/livetv_program.dart';
import '../models/livetv_server_status.dart';
import '../models/livetv_session.dart';
import '../models/media_grab_operation.dart';
import '../models/media_grabber_device.dart';
import '../models/media_provider_info.dart';
import '../models/media_subscription.dart';
import '../media/media_source_info.dart';
import '../media/media_sort.dart';
import '../utils/app_logger.dart';
import '../utils/endpoint_failover_interceptor.dart';
import '../utils/log_redaction_manager.dart';
import '../utils/external_ids.dart';
import '../utils/media_server_http_client.dart';
import '../utils/resolution_label.dart';
import '../utils/track_label_builder.dart';
import '../utils/watch_state_notifier.dart';
import '../exceptions/media_server_exceptions.dart';
import '../i18n/strings.g.dart';
import '../utils/jellyfin_time.dart';
import 'jellyfin_auth_header.dart';
import '../media/download_resolution.dart';
import 'api_cache.dart';
import 'download_artwork_helpers.dart';
import 'jellyfin_api_cache.dart';
import 'jellyfin_mappers.dart';
import 'jellyfin_media_info_mapper.dart';
import 'jellyfin_playback_bundle.dart';
import 'jellyfin_playback_urls.dart';
import 'jellyfin_trickplay_service.dart';
import 'playback_initialization_types.dart';
import 'scrub_preview_source.dart';
import '../mpv/mpv.dart';
part 'jellyfin_client/parts/browse.dart';
part 'jellyfin_client/parts/playback.dart';
part 'jellyfin_client/parts/watch_state.dart';
part 'jellyfin_client/parts/playlists.dart';
part 'jellyfin_client/parts/collections.dart';
part 'jellyfin_client/parts/file_info.dart';
part 'jellyfin_client/parts/live_tv.dart';
part 'jellyfin_client/parts/images_downloads.dart';
part 'jellyfin_client/parts/metadata_edit.dart';
/// [MediaServerClient] over a Jellyfin server.
///
/// Constructs from a [JellyfinConnection] and a [MediaServerHttpClient] (the
/// HTTP wrapper is backend-agnostic despite the name). Implements the full
/// neutral interface: browse, watch state, playlist read, playback session
/// reporting, and live TV via [LiveTvSupport].
class JellyfinClient
with
MediaServerCacheMixin,
_JellyfinBrowseMethods,
_JellyfinPlaybackMethods,
_JellyfinWatchStateMethods,
_JellyfinPlaylistMethods,
_JellyfinCollectionMethods,
_JellyfinFileInfoMethods,
_JellyfinLiveTvMethods,
_JellyfinImageDownloadMethods,
_JellyfinMetadataEditMethods
implements MediaServerClient, ScopedMediaServerClient, GracefullyCloseable {
JellyfinClient._({required this._connection, required this._http, FavoriteChannelsRepository? favoritesRepository})
: _favoritesRepository = favoritesRepository ?? const SharedPreferencesFavoriteChannelsRepository();
/// Build a fully-initialised [JellyfinClient]. Endpoint reachability is
/// raced before construction by onboarding/profile binding; this factory
/// keeps network I/O lazy so URL-builder tests don't need a live server.
///
/// Sends the full `Authorization: MediaBrowser …, Token="…"` header on
/// every request — that's what the official Jellyfin SDK (and Findroid by
/// extension) does. Modern Jellyfin servers behind reverse proxies often
/// reject requests that only carry the legacy `X-Emby-Token` header,
/// returning 404 from the proxy or a routing-level handler instead of
/// 401. We send `X-Emby-Token` too for old Emby/Jellyfin builds.
static Future<JellyfinClient> create(
JellyfinConnection connection, {
FavoriteChannelsRepository? favoritesRepository,
}) async {
// Register before any HTTP traffic so the very first probe URL doesn't
// leak the token verbatim. `LogRedactionManager.redact()` also has
// pattern-based fallbacks for `api_key=`, `X-Emby-Token`, and the
// `Authorization: MediaBrowser ... Token="..."` header.
LogRedactionManager.registerServer(connection.baseUrl, connection.accessToken);
String version = '1.0';
try {
final pkg = await PackageInfo.fromPlatform();
if (pkg.version.isNotEmpty) version = pkg.version;
} catch (_) {
// Tests / non-platform contexts — keep the fallback version.
}
final authHeader = buildJellyfinAuthHeader(
clientName: 'Plezy',
clientVersion: version,
deviceName: 'Plezy',
deviceId: connection.deviceId,
accessToken: connection.accessToken,
);
final headers = {
'Authorization': authHeader,
'X-Emby-Token': connection.accessToken,
'Accept': 'application/json',
// Jellyfin's session reporting endpoints (`/Sessions/Playing*`) reject
// any content-type carrying a `; charset=utf-8` suffix with 415 —
// pin to the SDK's exact wire format up-front.
'Content-Type': 'application/json',
};
late JellyfinClient client;
final http = _JellyfinFailoverHttpClient(
baseUrl: connection.baseUrl,
defaultHeaders: headers,
prioritizedEndpoints: connection.baseUrls,
onEndpointSwitch: (newBaseUrl, {required persist}) => client._handleEndpointSwitch(newBaseUrl, persist: persist),
);
client = JellyfinClient._(connection: connection, http: http, favoritesRepository: favoritesRepository);
return client;
}
/// Test-only factory that injects an [http.Client] so URL-builder tests
/// can capture the request URI without spinning up a real Jellyfin server.
@visibleForTesting
static JellyfinClient forTesting({
required JellyfinConnection connection,
required http.Client httpClient,
FavoriteChannelsRepository? favoritesRepository,
}) {
late JellyfinClient client;
final mediaHttp = _JellyfinFailoverHttpClient(
baseUrl: connection.baseUrl,
defaultHeaders: {'X-Emby-Token': connection.accessToken, 'Accept': 'application/json'},
prioritizedEndpoints: connection.baseUrls,
onEndpointSwitch: (newBaseUrl, {required persist}) => client._handleEndpointSwitch(newBaseUrl, persist: persist),
client: httpClient,
);
client = JellyfinClient._(connection: connection, http: mediaHttp, favoritesRepository: favoritesRepository);
return client;
}
/// Mutable so [isHealthy] can refresh `Policy.IsAdministrator` from the
/// `/Users/Me` probe response — admin status changed server-side should
/// propagate without forcing the user to re-auth.
JellyfinConnection _connection;
@override
JellyfinConnection get connection => _connection;
@override
final MediaServerHttpClient _http;
final FavoriteChannelsRepository _favoritesRepository;
bool _offlineMode = false;
/// Fired when the live `connection` snapshot diverges from the cached one
/// (currently only on admin-status change). [MultiServerManager] uses this
/// to re-broadcast status so admin-gated UI rebuilds.
FutureOr<void> Function(JellyfinConnection connection)? onConnectionUpdated;
Future<void> _handleEndpointSwitch(String newBaseUrl, {required bool persist}) async {
final changed = connection.baseUrl != newBaseUrl;
if (changed) {
appLogger.i('Applying Jellyfin endpoint switch', error: newBaseUrl);
_http.baseUrl = newBaseUrl;
_connection = _connection.copyWith(baseUrl: newBaseUrl);
LogRedactionManager.registerServer(newBaseUrl, connection.accessToken);
}
if (persist) {
await onConnectionUpdated?.call(_connection);
}
}
/// Read-only view of the headers attached to every outgoing request.
/// Test-only entry point for asserting the SDK-style `MediaBrowser`
/// Authorization shape — Findroid (and the official SDK) sends the same
/// thing.
@visibleForTesting
Map<String, String> get defaultHeadersForTesting => Map.unmodifiable(_http.defaultHeaders);
/// Image-path absolutizer scoped to this client's [connection]. Shared with
/// [JellyfinApiCache] (which constructs its own from the connection row's
/// `configJson`) so cache reads carry the same absolute URLs as live API
/// reads — see [JellyfinImageAbsolutizer].
JellyfinImageAbsolutizer get _absolutizer =>
JellyfinImageAbsolutizer(baseUrl: connection.baseUrl, accessToken: connection.accessToken);
@override
String? _absolutizeImagePath(String? path) => _absolutizer.absolutize(path);
@override
MediaItem? _mapItem(Map<String, dynamic> json) =>
JellyfinMappers.mediaItem(json, serverId: serverId, serverName: serverName, absolutizer: _absolutizer);
@override
List<MediaItem> _mapItems(Iterable<Map<String, dynamic>> items) =>
items.map(_mapItem).whereType<MediaItem>().toList();
@override
String get serverId => connection.serverMachineId;
@override
String get scopedServerId => connection.id;
@override
String? get serverName => connection.serverName;
@override
MediaBackend get backend => MediaBackend.jellyfin;
@override
ServerCapabilities get capabilities => ServerCapabilities.jellyfin;
/// Jellyfin doesn't expose a per-server played-threshold pref, so we mirror
/// Plex's default of 90%.
@override
double get watchedThreshold => 0.9;
@override
void close() => _http.close();
@override
Future<void> closeGracefully({Duration drainTimeout = const Duration(seconds: 2)}) =>
_http.closeGracefully(drainTimeout: drainTimeout);
/// Reachable *and* token-valid. We probe `/Users/Me` (auth-required)
/// rather than `/System/Info/Public` so a revoked token surfaces as
/// unhealthy on the very next sweep, instead of waiting for the first
/// real call to 401.
///
/// Side-effect: when the response body carries a fresh
/// `Policy.IsAdministrator` that differs from the cached one, refresh the
/// connection so admin-gated UI catches the server-side change without
/// requiring re-auth (see [onConnectionUpdated]).
///
/// 401/403 surfaces as [HealthStatus.authError] so the manager can
/// distinguish a revoked token from a generic transport failure.
@override
Future<HealthStatus> checkHealth() async {
try {
final response = await _http.get('/Users/Me', timeout: const Duration(seconds: 8));
final ok = response.statusCode >= 200 && response.statusCode < 300;
if (ok) {
final data = response.data;
if (data is Map<String, dynamic>) {
final policy = data['Policy'];
if (policy is Map<String, dynamic>) {
final fresh = policy['IsAdministrator'] as bool?;
if (fresh != null && fresh != _connection.isAdministrator) {
_connection = _connection.copyWith(isAdministrator: fresh);
final listener = onConnectionUpdated;
if (listener != null) {
try {
await Future.sync(() => listener(_connection));
} catch (e, st) {
appLogger.w('Failed to handle Jellyfin connection update', error: e, stackTrace: st);
}
}
}
}
}
return HealthStatus.online;
}
if (response.statusCode == 401 || response.statusCode == 403) {
return HealthStatus.authError;
}
return HealthStatus.offline;
} on MediaServerHttpException catch (e) {
if (e.statusCode == 401 || e.statusCode == 403) return HealthStatus.authError;
return HealthStatus.offline;
} catch (_) {
return HealthStatus.offline;
}
}
@override
Future<bool> isHealthy() async => (await checkHealth()) == HealthStatus.online;
/// Fetch the authenticated user's `Configuration` (audio/subtitle language
/// prefs, auto-select flag) so the player can apply per-user defaults.
/// Returns null on transport failures — caller treats as "no preference".
Future<JellyfinUserProfile?> fetchUserProfile() async {
try {
final response = await _http.get('/Users/Me');
throwIfHttpError(response);
final data = response.data;
if (data is! Map<String, dynamic>) return null;
return JellyfinUserProfile.fromUserDto(data);
} catch (e, st) {
appLogger.w('JellyfinClient.fetchUserProfile failed', error: e, stackTrace: st);
return null;
}
}
@override
Future<String?> getMachineIdentifier() async {
try {
final response = await _http.get('/System/Info/Public');
throwIfHttpError(response);
final data = response.data;
if (data is Map<String, dynamic>) {
return data['Id'] as String?;
}
return connection.serverMachineId;
} catch (e) {
appLogger.w('JellyfinClient: getMachineIdentifier failed: $e');
return connection.serverMachineId;
}
}
@override
bool get isOfflineMode => _offlineMode;
@override
void setOfflineMode(bool offline) {
_offlineMode = offline;
}
/// Expose the Jellyfin cache through the [MediaServerClient] interface so
/// the shared `fetchWithCacheFallback` / `fetchWithCacheFirst` helpers
/// route through the correct backend's cache substrate.
@override
ApiCache get cache => JellyfinApiCache.instance;
}
class _JellyfinFailoverHttpClient extends MediaServerHttpClient {
_JellyfinFailoverHttpClient({
super.client,
required super.baseUrl,
required super.defaultHeaders,
required List<String> prioritizedEndpoints,
required this.onEndpointSwitch,
}) : _endpointManager = prioritizedEndpoints.length > 1 ? EndpointFailoverManager(prioritizedEndpoints) : null;
final EndpointFailoverManager? _endpointManager;
final Future<void> Function(String newBaseUrl, {required bool persist}) onEndpointSwitch;
bool _failoverSwitching = false;
@override
Future<MediaServerResponse> get(
String path, {
Map<String, dynamic>? queryParameters,
Map<String, String>? headers,
Duration? timeout,
AbortController? abort,
}) async {
final gen = _endpointManager?.generation;
try {
final response = await super.get(
path,
queryParameters: queryParameters,
headers: headers,
timeout: timeout,
abort: abort,
);
if (!_shouldAttemptFailover(statusCode: response.statusCode) || !_canFailover(gen)) {
return response;
}
return _retryNextEndpoint(
path,
queryParameters: queryParameters,
headers: headers,
timeout: timeout,
abort: abort,
);
} on MediaServerHttpException catch (e) {
if (!_shouldAttemptFailover(exception: e) || !_canFailover(gen)) rethrow;
return _retryNextEndpoint(
path,
queryParameters: queryParameters,
headers: headers,
timeout: timeout,
abort: abort,
);
}
}
bool _canFailover(int? requestGeneration) {
final manager = _endpointManager;
return manager != null && !_failoverSwitching && requestGeneration == manager.generation;
}
bool _shouldAttemptFailover({MediaServerHttpException? exception, int? statusCode}) {
final e = exception;
if (e != null) {
if (e.isTransient) return true;
final sc = e.statusCode;
return sc != null && sc >= 500 && sc <= 599;
}
final sc = statusCode;
return sc != null && sc >= 500 && sc <= 599;
}
Future<MediaServerResponse> _retryNextEndpoint(
String path, {
Map<String, dynamic>? queryParameters,
Map<String, String>? headers,
Duration? timeout,
AbortController? abort,
}) async {
final manager = _endpointManager;
if (manager == null) {
throw StateError('No Jellyfin failover endpoints configured');
}
if (!manager.hasFallback) {
final resetBaseUrl = manager.resetToFirst();
if (resetBaseUrl != null) {
await onEndpointSwitch(resetBaseUrl, persist: false);
}
throw MediaServerHttpException(
type: MediaServerHttpErrorType.connectionError,
message: 'All Jellyfin endpoints exhausted',
);
}
final failedEndpoint = manager.current;
final nextBaseUrl = manager.moveToNext();
if (nextBaseUrl == null) {
throw MediaServerHttpException(
type: MediaServerHttpErrorType.connectionError,
message: 'All Jellyfin endpoints exhausted',
);
}
_failoverSwitching = true;
try {
appLogger.i('Switching Jellyfin endpoint after GET failure', error: {'from': failedEndpoint, 'to': nextBaseUrl});
await onEndpointSwitch(nextBaseUrl, persist: false);
final response = await super.get(
path,
queryParameters: queryParameters,
headers: headers,
timeout: timeout,
abort: abort,
);
if (response.statusCode < 400) {
appLogger.i('Jellyfin endpoint failover retry succeeded', error: {'newEndpoint': nextBaseUrl});
await onEndpointSwitch(nextBaseUrl, persist: true);
} else if (_shouldAttemptFailover(statusCode: response.statusCode) && !manager.hasFallback) {
final resetBaseUrl = manager.resetToFirst();
if (resetBaseUrl != null) {
await onEndpointSwitch(resetBaseUrl, persist: false);
}
throw MediaServerHttpException(
type: MediaServerHttpErrorType.unknown,
statusCode: response.statusCode,
message: 'All Jellyfin endpoints exhausted',
);
}
return response;
} catch (_) {
final resetBaseUrl = manager.resetToFirst();
if (resetBaseUrl != null) {
await onEndpointSwitch(resetBaseUrl, persist: false);
}
rethrow;
} finally {
_failoverSwitching = false;
}
}
}