Files
plezy/lib/services/jellyfin_client.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

519 lines
22 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/episode_collection.dart';
import '../media/library_filter_result.dart';
import '../media/library_first_character.dart';
import '../media/library_query.dart';
import 'favorite_channels_repository.dart';
import 'live_session_tracker.dart';
import 'file_info_parser.dart';
import 'library_query_translator.dart';
import '../media/media_filter.dart';
import '../media/live_tv_support.dart';
import '../media/lyrics.dart';
import '../media/media_backend.dart';
import '../media/media_browser_dialect.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/ids.dart';
import '../media/media_server_client.dart';
import '../media/playback_report_metadata.dart';
import '../media/server_capabilities.dart';
import '../models/audio_quality_preset.dart';
import '../models/jellyfin/jellyfin_user_profile.dart';
import '../models/livetv_capture_buffer.dart';
import '../models/livetv_channel.dart';
import '../models/livetv_program.dart';
import '../media/media_source_info.dart';
import '../media/media_sort.dart';
import '../media/media_version.dart';
import '../utils/app_logger.dart';
import '../utils/device_identity.dart';
import '../utils/failover_http_client.dart';
import '../utils/media_server_retry.dart';
import '../utils/future_extensions.dart';
import '../utils/media_server_timeouts.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 '../exceptions/media_server_exceptions.dart';
import '../i18n/strings.g.dart';
import '../utils/json_utils.dart';
import '../utils/jellyfin_time.dart';
import 'jellyfin_auth_header.dart';
import 'jellyfin_endpoint_discovery.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 'media_browser_paths.dart';
import 'playback_initialization_types.dart';
import 'scrub_preview_source.dart';
import 'subtitle_preference.dart';
import 'track_selection_service.dart';
import '../mpv/mpv.dart';
part 'jellyfin_client/parts/browse.dart';
part 'jellyfin_client/parts/music.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';
/// Canonical declarations of the [JellyfinClient] internals that the `part`
/// mixins call into.
///
/// Every part mixin is `on _JellyfinClientInternals`, so each shared member is
/// declared exactly once here instead of being re-declared per file. Members
/// used by a single part stay declared in that part.
mixin _JellyfinClientInternals on MediaServerCacheMixin {
JellyfinConnection get connection;
MediaBrowserDialect get dialect;
MediaBrowserPaths get paths;
FailoverHttpClient get _http;
MediaItem? _mapItem(Map<String, dynamic> json);
List<MediaItem> _mapItems(Iterable<Map<String, dynamic>> items);
String? _absolutizeImagePath(String? path);
/// Row metadata Jellyfin volunteers on `/Items` list responses but Emby
/// withholds unless it is named in `Fields`.
///
/// Measured on Emby 4.9.5 against a movie that carries them all: a row built
/// from [_baseBrowseFields] came back with no `ProductionYear`,
/// `OfficialRating`, `PremiereDate` or `DateCreated`, while the same query
/// naming them returned `2011`, `PG-13`, the premiere date and the library-add
/// time. Jellyfin 10.11 includes the first three in every row regardless.
/// Emby's *detail* route volunteers everything, so only list rows are
/// affected — but that is every card in the app, which would otherwise lose
/// its year and age-rating badge.
///
/// `DateCreated` is load-bearing beyond display: it is the `addedAt` every
/// recency-ordered surface degrades to when a row has never been played.
///
/// `UserDataLastPlayedDate` is the odd one out: it is not an `ItemFields`
/// member but an Emby-specific token, and it is the only way to get
/// `UserData.LastPlayedDate` onto a list row. Measured on Emby 4.9.5: the
/// played date is absent under `Fields=UserData`, `EnableUserData=true` and the
/// user-scoped `Ids=` form, and present only on the single-item detail route or
/// when this token is named. Jellyfin 10.11 volunteers the date on every row and
/// accepts the token without changing its responses, but since it is undocumented
/// there, only Emby is asked for it.
///
/// Every row set needs it, not just the recency-ordered ones: a null played date
/// on a *watched* row makes [JellyfinApiCache.applyWatchState] stamp
/// `DateTime.now()`, so an offline watch-state pull over episode rows would
/// rewrite the cached play time of everything it touched.
static const _embyWithheldRowFields = [
'ProductionYear',
'OfficialRating',
'PremiereDate',
'DateCreated',
'UserDataLastPlayedDate',
];
/// Append the fields this dialect withholds, skipping any the set already
/// names so Jellyfin's request strings stay byte-identical.
String _withDialectRowFields(String fields) {
if (dialect != MediaBrowserDialect.emby) return fields;
final present = fields.split(',').map((field) => field.trim()).toSet();
final missing = _embyWithheldRowFields.where((field) => !present.contains(field));
return missing.isEmpty ? fields : '$fields,${missing.join(',')}';
}
String get _browseFields => _withDialectRowFields(_baseBrowseFields);
String get _hubRowFields => _withDialectRowFields(_baseHubRowFields);
String get _episodeRowFields => _withDialectRowFields(_baseEpisodeRowFields);
String get _folderBrowseFields => _withDialectRowFields(_baseFolderBrowseFields);
String get _folderRowFields => _withDialectRowFields(_baseFolderRowFields);
String get _musicAlbumRowFields => _withDialectRowFields(_baseMusicAlbumRowFields);
String get _musicTrackRowFields => _withDialectRowFields(_baseMusicTrackRowFields);
String get _queueFields => _withDialectRowFields(_baseQueueFields);
}
/// [MediaServerClient] over a MediaBrowser-family server — Jellyfin or Emby.
///
/// 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].
///
/// Jellyfin forked from Emby 3.5.2 and the wire contract is still ~95% shared,
/// so one client serves both. [dialect] selects the divergent routes (via
/// [paths]) and the features that exist on only one side — see
/// [MediaBrowserDialect].
class JellyfinClient
with
MediaServerCacheMixin,
_JellyfinClientInternals,
_JellyfinBrowseMethods,
_JellyfinMusicMethods,
_JellyfinPlaybackMethods,
_JellyfinWatchStateMethods,
_JellyfinPlaylistMethods,
_JellyfinCollectionMethods,
_JellyfinFileInfoMethods,
_JellyfinLiveTvMethods,
_JellyfinImageDownloadMethods,
_JellyfinMetadataEditMethods
implements
MediaServerClient,
SeasonEpisodePagingClient,
MediaDeletionPermissionClient,
ScopedMediaServerClient,
GracefullyCloseable {
JellyfinClient._({required this._connection, required this._http, FavoriteChannelsRepository? favoritesRepository})
: _favoritesRepository = favoritesRepository ?? const SharedPreferencesFavoriteChannelsRepository(),
_paths = MediaBrowserPaths(dialect: _connection.dialect, userId: _connection.userId);
/// 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.
///
/// Emby accepts this header pair verbatim: it authored both the
/// `MediaBrowser` Authorization scheme and `X-Emby-Token`, so no dialect
/// branch is needed here (verified against Emby 4.9.5).
static Future<JellyfinClient> create(
JellyfinConnection connection, {
FavoriteChannelsRepository? favoritesRepository,
void Function()? onAllEndpointsExhausted,
}) async {
// Register every normalized connection endpoint and the token before any
// HTTP traffic. Orchestration logs contain no literals; this additionally
// protects unavoidable network-layer diagnostics.
_registerConnectionDiagnostics(connection);
final endpointDiscovery = JellyfinEndpointDiscovery(dialect: connection.dialect);
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.
}
// Raw, not header-sanitized: [buildJellyfinAuthHeader] percent-encodes it.
String? deviceName;
try {
final resolved = (await DeviceIdentityService.resolve()).deviceName?.trim();
if (resolved != null && resolved.isNotEmpty) deviceName = resolved;
} catch (_) {
// Tests / non-platform contexts — keep the fallback name.
}
final authHeader = buildJellyfinAuthHeader(
clientName: 'Plezy',
clientVersion: version,
deviceName: 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 = FailoverHttpClient(
baseUrl: connection.baseUrl,
defaultHeaders: headers,
logLabel: 'Jellyfin',
// Same pool tuning Plex uses: the home fan-out issues several concurrent
// requests per pass, and the untuned dart:io default drops idle
// connections after 15s — a fresh TLS handshake per request on a
// high-RTT/CDN link.
usePlexApiClient: true,
prioritizedEndpoints: connection.baseUrls,
onEndpointSwitch: (newBaseUrl, {required persist}) => client._handleEndpointSwitch(newBaseUrl, persist: persist),
onAllEndpointsExhausted: onAllEndpointsExhausted,
validateCandidate: (candidateBaseUrl, abort) async =>
(await endpointDiscovery.probe(candidateBaseUrl, abort: abort)).machineId == connection.serverMachineId,
);
client = JellyfinClient._(connection: connection, http: http, favoritesRepository: favoritesRepository);
return client;
}
/// Test-only factory that injects independent authenticated-application and
/// unauthenticated public-probe clients.
@visibleForTesting
static JellyfinClient forTesting({
required JellyfinConnection connection,
required http.Client httpClient,
http.Client Function()? endpointProbeHttpClientFactory,
FavoriteChannelsRepository? favoritesRepository,
void Function()? onAllEndpointsExhausted,
}) {
_registerConnectionDiagnostics(connection);
final endpointDiscovery = JellyfinEndpointDiscovery(
dialect: connection.dialect,
testHttpClientFactory: endpointProbeHttpClientFactory,
);
late JellyfinClient client;
final mediaHttp = FailoverHttpClient(
baseUrl: connection.baseUrl,
defaultHeaders: {'X-Emby-Token': connection.accessToken, 'Accept': 'application/json'},
logLabel: 'Jellyfin',
prioritizedEndpoints: connection.baseUrls,
onEndpointSwitch: (newBaseUrl, {required persist}) => client._handleEndpointSwitch(newBaseUrl, persist: persist),
onAllEndpointsExhausted: onAllEndpointsExhausted,
validateCandidate: (candidateBaseUrl, abort) async =>
(await endpointDiscovery.probe(candidateBaseUrl, abort: abort)).machineId == connection.serverMachineId,
client: httpClient,
);
client = JellyfinClient._(connection: connection, http: mediaHttp, favoritesRepository: favoritesRepository);
return client;
}
/// Mutable so [isHealthy] can refresh `Policy.IsAdministrator` from the
/// current-user probe response — admin status changed server-side should
/// propagate without forcing the user to re-auth.
JellyfinConnection _connection;
@override
JellyfinConnection get connection => _connection;
/// Which MediaBrowser dialect this server speaks. Fixed for the lifetime of
/// the client: an endpoint switch can move the base URL but never turns a
/// Jellyfin server into an Emby one.
@override
MediaBrowserDialect get dialect => _connection.dialect;
/// Route builders for the endpoints where the two dialects diverge.
@override
MediaBrowserPaths get paths => _paths;
final MediaBrowserPaths _paths;
@override
final FailoverHttpClient _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;
static void _registerConnectionDiagnostics(JellyfinConnection connection) {
LogRedactionManager.registerToken(connection.accessToken);
for (final baseUrl in connection.baseUrls) {
LogRedactionManager.registerServerUrl(baseUrl);
}
}
Future<void> _handleEndpointSwitch(String newBaseUrl, {required bool persist}) async {
LogRedactionManager.registerServerUrl(newBaseUrl);
final changed = connection.baseUrl != newBaseUrl;
if (changed) {
appLogger.i('Applying Jellyfin endpoint switch');
_http.baseUrl = newBaseUrl;
_connection = _connection.copyWith(baseUrl: newBaseUrl);
}
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,
dialect: dialect,
);
@override
List<MediaItem> _mapItems(Iterable<Map<String, dynamic>> items) =>
items.map(_mapItem).whereType<MediaItem>().toList();
@override
ServerId get serverId => ServerId(connection.serverMachineId);
@override
String get scopedServerId => connection.id;
@override
String? get serverName => connection.serverName;
@override
MediaBackend get backend => dialect.backend;
@override
ServerCapabilities get capabilities => switch (dialect) {
MediaBrowserDialect.jellyfin => ServerCapabilities.jellyfin,
MediaBrowserDialect.emby => ServerCapabilities.emby,
};
/// Neither dialect exposes a per-server played-threshold pref, so we mirror
/// Plex's default of 90%.
@override
double get watchedThreshold => 0.9;
/// Both dialects mark an item played from `/Sessions/Playing/Stopped`
/// themselves (server `MaxResumePct`, default 90%), so the in-player
/// auto-scrobble must not also POST the played route — that double-scrobbles
/// via the Trakt plugin (#1287). Manual mark-watched still writes it.
@override
bool get marksWatchedOnPlaybackStopped => true;
@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 the current-user route
/// ([MediaBrowserPaths.currentUser], 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` or primary profile-picture tag that differs
/// from the cached value, refresh the connection so admin-gated UI and
/// profile avatars catch server-side changes 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(paths.currentUser, timeout: MediaServerTimeouts.jellyfinProbe);
final ok = response.statusCode >= 200 && response.statusCode < 300;
if (ok) {
final data = response.data;
if (data is Map<String, dynamic>) {
final policy = data['Policy'];
final freshIsAdministrator = policy is Map<String, dynamic> ? policy['IsAdministrator'] as bool? : null;
final freshPrimaryImageTag = JellyfinConnection.readPrimaryImageTag(data);
final isAdministratorChanged =
freshIsAdministrator != null && freshIsAdministrator != _connection.isAdministrator;
final primaryImageTagChanged = freshPrimaryImageTag != _connection.primaryImageTag;
if (isAdministratorChanged || primaryImageTagChanged) {
_connection = _connection.copyWith(
isAdministrator: freshIsAdministrator,
primaryImageTag: freshPrimaryImageTag,
clearPrimaryImageTag: primaryImageTagChanged && freshPrimaryImageTag == null,
);
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(paths.currentUser);
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;
}