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.
519 lines
22 KiB
Dart
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;
|
|
}
|