Files
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

430 lines
14 KiB
Dart

import '../media/media_backend.dart';
import '../media/media_browser_dialect.dart';
import '../models/plex/plex_home_user.dart';
import '../services/plex_auth_service.dart';
import '../utils/json_utils.dart';
import '../utils/url_utils.dart';
/// Identifier of a backend kind a [Connection] points at. Lighter-weight than
/// [MediaBackend] for places that only care about persistence/auth shape
/// (e.g. database column values).
enum ConnectionKind {
plex,
jellyfin,
emby;
String get id => switch (this) {
ConnectionKind.plex => 'plex',
ConnectionKind.jellyfin => 'jellyfin',
ConnectionKind.emby => 'emby',
};
static ConnectionKind fromId(String id) => switch (id) {
'plex' => ConnectionKind.plex,
'jellyfin' => ConnectionKind.jellyfin,
'emby' => ConnectionKind.emby,
_ => throw ArgumentError('Unknown ConnectionKind id: $id'),
};
MediaBackend get backend => switch (this) {
ConnectionKind.plex => MediaBackend.plex,
ConnectionKind.jellyfin => MediaBackend.jellyfin,
ConnectionKind.emby => MediaBackend.emby,
};
/// The MediaBrowser dialect this kind speaks, or `null` for Plex.
MediaBrowserDialect? get dialect => switch (this) {
ConnectionKind.plex => null,
ConnectionKind.jellyfin => MediaBrowserDialect.jellyfin,
ConnectionKind.emby => MediaBrowserDialect.emby,
};
static ConnectionKind fromDialect(MediaBrowserDialect dialect) => switch (dialect) {
MediaBrowserDialect.jellyfin => ConnectionKind.jellyfin,
MediaBrowserDialect.emby => ConnectionKind.emby,
};
}
/// Health snapshot for a connection. Updated by the orchestrator each time a
/// session is established or refreshed.
enum ConnectionStatus { unknown, online, offline, authError, disabled }
/// A media server connection — a unit of authentication the user added.
///
/// A `PlexAccountConnection` carries one Plex account + its discovered servers + an
/// optional active Home profile. A `JellyfinConnection` is a single server +
/// user. Most users only ever add one connection.
sealed class Connection {
String get id;
ConnectionKind get kind;
String get displayName;
ConnectionStatus get status;
DateTime get createdAt;
DateTime? get lastAuthenticatedAt;
/// Backend kind as a [MediaBackend] — for UI that branches on backend
/// (badges, etc.). Just a passthrough to [kind.backend].
MediaBackend get backend => kind.backend;
/// Primary label shown in connection-list UIs. Plex shows the active
/// profile/account name; Jellyfin shows the server name.
String get displayLabel;
/// Secondary line shown beneath [displayLabel] in connection-list UIs.
/// Plex: server count; Jellyfin: `userName · baseUrl`. May be null when
/// no useful subtitle exists.
String? get displaySubtitle;
/// Backend-specific config payload, persisted as JSON. Each subclass
/// defines the schema.
Map<String, Object?> toConfigJson();
}
/// A Plex account connection.
///
/// Fields here mirror what [PlexAuthService] gathers during PIN OAuth: an
/// account token (long-lived), the per-device client identifier (so plex.tv
/// doesn't see a "new device" each launch), and the optional Home user the
/// user has switched into.
class PlexAccountConnection extends Connection {
@override
final String id;
@override
final ConnectionStatus status;
@override
final DateTime createdAt;
@override
final DateTime? lastAuthenticatedAt;
/// plex.tv account access token.
final String accountToken;
/// Per-device client identifier. Stable across launches.
final String clientIdentifier;
/// Display name shown for this connection (typically the Plex account email
/// or username, fallback "Plex").
final String accountLabel;
/// Active Home user, or `null` for the main account.
final PlexHomeUser? activeProfile;
/// Servers discovered for this account (cached). Populated by the auth
/// flow and refreshed periodically.
final List<PlexServer> servers;
PlexAccountConnection({
required this.id,
required this.accountToken,
required this.clientIdentifier,
required this.accountLabel,
this.activeProfile,
this.servers = const [],
this.status = ConnectionStatus.unknown,
required this.createdAt,
this.lastAuthenticatedAt,
});
@override
ConnectionKind get kind => ConnectionKind.plex;
@override
String get displayName => activeProfile != null && activeProfile!.title.isNotEmpty
? '${activeProfile!.title} · $accountLabel'
: accountLabel;
@override
String get displayLabel => displayName;
@override
String? get displaySubtitle => servers.length == 1 ? '1 Plex server' : '${servers.length} Plex servers';
PlexAccountConnection copyWith({
String? id,
String? accountToken,
String? clientIdentifier,
String? accountLabel,
PlexHomeUser? activeProfile,
bool clearActiveProfile = false,
List<PlexServer>? servers,
ConnectionStatus? status,
DateTime? createdAt,
DateTime? lastAuthenticatedAt,
}) {
return PlexAccountConnection(
id: id ?? this.id,
accountToken: accountToken ?? this.accountToken,
clientIdentifier: clientIdentifier ?? this.clientIdentifier,
accountLabel: accountLabel ?? this.accountLabel,
activeProfile: clearActiveProfile ? null : (activeProfile ?? this.activeProfile),
servers: servers ?? this.servers,
status: status ?? this.status,
createdAt: createdAt ?? this.createdAt,
lastAuthenticatedAt: lastAuthenticatedAt ?? this.lastAuthenticatedAt,
);
}
@override
Map<String, Object?> toConfigJson() {
return {
'accountToken': accountToken,
'clientIdentifier': clientIdentifier,
'accountLabel': accountLabel,
'activeProfile': activeProfile?.toJson(),
'servers': servers.map((s) => s.toJson()).toList(),
};
}
factory PlexAccountConnection.fromConfigJson({
required String id,
required Map<String, Object?> json,
required ConnectionStatus status,
required DateTime createdAt,
DateTime? lastAuthenticatedAt,
}) {
final profileJson = json['activeProfile'];
final activeProfile = profileJson is Map<String, dynamic> ? PlexHomeUser.fromJson(profileJson) : null;
final serversJson = json['servers'];
final servers = serversJson is List
? serversJson.whereType<Map<String, dynamic>>().map(PlexServer.fromJson).toList()
: <PlexServer>[];
return PlexAccountConnection(
id: id,
accountToken: json['accountToken'] as String? ?? '',
clientIdentifier: json['clientIdentifier'] as String? ?? '',
accountLabel: json['accountLabel'] as String? ?? 'Plex',
activeProfile: activeProfile,
servers: servers,
status: status,
createdAt: createdAt,
lastAuthenticatedAt: lastAuthenticatedAt,
);
}
}
/// A single-server connection to a MediaBrowser-family server — Jellyfin or its
/// Emby ancestor. [dialect] selects which of the two wire dialects this
/// connection speaks; every other field has the same meaning on both.
class JellyfinConnection extends Connection {
@override
final String id;
@override
final ConnectionStatus status;
@override
final DateTime createdAt;
@override
final DateTime? lastAuthenticatedAt;
/// Which MediaBrowser dialect this server speaks. Drives [kind], [backend]
/// and every route/capability delta in [JellyfinClient].
final MediaBrowserDialect dialect;
/// Active server base URL, no trailing slash. e.g. `https://jellyfin.home.lan`.
final String baseUrl;
/// Candidate server URLs for this server, with [baseUrl] first.
/// Existing installs only have [baseUrl]; deserialization backfills this.
final List<String> baseUrls;
/// Server's reported name (System/Info).
final String serverName;
/// Server's machine identifier (System/Info `Id`).
final String serverMachineId;
/// Authenticated user id. A UUID on Jellyfin, an opaque hex string on Emby.
final String userId;
/// Authenticated user's display name.
final String userName;
/// Long-lived access token from `/Users/AuthenticateByName`.
final String accessToken;
/// Per-device client identifier (same value sent in the
/// `Authorization: MediaBrowser DeviceId="..."` header).
final String deviceId;
/// Whether this user is a server admin (`/Users/{id}.Policy.IsAdministrator`).
/// Captured at auth time so the UI can gate admin-only entries (delete,
/// match/unmatch, edit metadata) without an extra round-trip.
final bool isAdministrator;
/// The authenticated user's `PrimaryImageTag`, or `null` when they have no
/// profile picture. The server omits the key entirely in that case, and the
/// tag is `MD5(imagePath + lastModified)` so it changes on every upload —
/// which makes the derived avatar URL self-invalidating. Captured at auth
/// time and refreshed by [JellyfinClient.checkHealth].
final String? primaryImageTag;
JellyfinConnection({
required this.id,
required String baseUrl,
List<String>? baseUrls,
required this.serverName,
required this.serverMachineId,
required this.userId,
required this.userName,
required this.accessToken,
required this.deviceId,
this.dialect = MediaBrowserDialect.jellyfin,
this.isAdministrator = false,
this.primaryImageTag,
this.status = ConnectionStatus.unknown,
required this.createdAt,
this.lastAuthenticatedAt,
}) : baseUrl = canonicalizeBaseUrl(baseUrl),
baseUrls = _normalizeBaseUrls(baseUrl, baseUrls);
@override
ConnectionKind get kind => ConnectionKind.fromDialect(dialect);
@override
String get displayName => '$userName · $serverName';
@override
String get displayLabel => serverName;
@override
String? get displaySubtitle {
final extraCount = baseUrls.length - 1;
final suffix = extraCount > 0 ? ' +$extraCount' : '';
return '$userName · ${_truncateUrl(baseUrl)}$suffix';
}
static String _truncateUrl(String url) {
if (url.length <= 40) return url;
return '${url.substring(0, 37)}…';
}
static List<String> _normalizeBaseUrls(String activeBaseUrl, List<String>? urls) {
final result = <String>[];
final seen = <String>{};
void add(String url) {
final normalized = canonicalizeBaseUrl(url);
if (normalized.isEmpty || !seen.add(normalized)) return;
result.add(normalized);
}
add(activeBaseUrl);
for (final url in urls ?? const <String>[]) {
add(url);
}
return List.unmodifiable(result);
}
JellyfinConnection copyWith({
String? id,
String? baseUrl,
List<String>? baseUrls,
String? serverName,
String? serverMachineId,
String? userId,
String? userName,
String? accessToken,
String? deviceId,
MediaBrowserDialect? dialect,
bool? isAdministrator,
String? primaryImageTag,
/// Deleting a profile picture drops `PrimaryImageTag` from the user DTO, so
/// a refresh must be able to null the cached value — a bare
/// `primaryImageTag: null` is indistinguishable from "unchanged".
bool clearPrimaryImageTag = false,
ConnectionStatus? status,
DateTime? createdAt,
DateTime? lastAuthenticatedAt,
}) {
final nextBaseUrl = baseUrl ?? this.baseUrl;
return JellyfinConnection(
id: id ?? this.id,
baseUrl: nextBaseUrl,
baseUrls: baseUrls ?? this.baseUrls,
serverName: serverName ?? this.serverName,
serverMachineId: serverMachineId ?? this.serverMachineId,
userId: userId ?? this.userId,
userName: userName ?? this.userName,
accessToken: accessToken ?? this.accessToken,
deviceId: deviceId ?? this.deviceId,
dialect: dialect ?? this.dialect,
isAdministrator: isAdministrator ?? this.isAdministrator,
primaryImageTag: clearPrimaryImageTag ? null : (primaryImageTag ?? this.primaryImageTag),
status: status ?? this.status,
createdAt: createdAt ?? this.createdAt,
lastAuthenticatedAt: lastAuthenticatedAt ?? this.lastAuthenticatedAt,
);
}
/// The persisted payload deliberately omits [dialect]: the `connections.kind`
/// column is the authoritative, indexed discriminator and
/// [JellyfinConnection.fromConfigJson] receives it from there.
@override
Map<String, Object?> toConfigJson() {
return {
'baseUrl': baseUrl,
'baseUrls': baseUrls,
'serverName': serverName,
'serverMachineId': serverMachineId,
'userId': userId,
'userName': userName,
'accessToken': accessToken,
'deviceId': deviceId,
'isAdministrator': isAdministrator,
'primaryImageTag': primaryImageTag,
};
}
factory JellyfinConnection.fromConfigJson({
required String id,
required Map<String, Object?> json,
required ConnectionStatus status,
required DateTime createdAt,
DateTime? lastAuthenticatedAt,
MediaBrowserDialect dialect = MediaBrowserDialect.jellyfin,
}) {
final rawBaseUrls = json['baseUrls'];
final baseUrls = rawBaseUrls is List ? rawBaseUrls.whereType<String>().toList(growable: false) : const <String>[];
final rawBaseUrl = json['baseUrl'] as String?;
final baseUrl = rawBaseUrl != null && rawBaseUrl.isNotEmpty
? rawBaseUrl
: (baseUrls.isNotEmpty ? baseUrls.first : '');
return JellyfinConnection(
id: id,
baseUrl: baseUrl,
baseUrls: baseUrls,
dialect: dialect,
serverName: json['serverName'] as String? ?? dialect.productName,
serverMachineId: json['serverMachineId'] as String? ?? '',
userId: json['userId'] as String? ?? '',
userName: json['userName'] as String? ?? '',
accessToken: json['accessToken'] as String? ?? '',
deviceId: json['deviceId'] as String? ?? '',
isAdministrator: json['isAdministrator'] as bool? ?? false,
primaryImageTag: normalizePrimaryImageTag(json['primaryImageTag']),
status: status,
createdAt: createdAt,
lastAuthenticatedAt: lastAuthenticatedAt,
);
}
/// Tolerant read of a Jellyfin user DTO's `PrimaryImageTag`.
///
/// The tag is decorative — a fork or a drifted scalar type must never brick
/// sign-in — so this coerces through [readStringField] rather than casting,
/// and collapses absent/blank to `null` ("no picture").
static String? readPrimaryImageTag(Map<String, Object?> userDto) =>
normalizePrimaryImageTag(readStringField(userDto, 'PrimaryImageTag'));
static String? normalizePrimaryImageTag(Object? raw) {
final tag = raw?.toString().trim();
return tag == null || tag.isEmpty ? null : tag;
}
}