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.
254 lines
11 KiB
Dart
254 lines
11 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:drift/drift.dart';
|
|
|
|
import '../database/app_database.dart';
|
|
|
|
typedef JellyfinItemCacheKey = ({String scopeId, String machineId, String userId, String itemId});
|
|
typedef JellyfinCacheItem = ({ApiCacheData cacheRow, JellyfinItemCacheKey key});
|
|
typedef ResolvedJellyfinCacheItem = ({ApiCacheData cacheRow, ConnectionRow connection, JellyfinItemCacheKey key});
|
|
|
|
/// Canonical MediaBrowser connection and item-cache key resolution.
|
|
class JellyfinCacheResolver {
|
|
JellyfinCacheResolver(this.database);
|
|
|
|
final AppDatabase database;
|
|
|
|
static const _likeEscape = r'\';
|
|
static const _usersMarker = ':/Users/';
|
|
static const _itemsMarker = '/Items/';
|
|
|
|
static Expression<bool> _mediaBrowserKind(GeneratedColumn<String> kind) =>
|
|
kind.equals('jellyfin') | kind.equals('emby');
|
|
|
|
Expression<bool> itemKeyPredicate(GeneratedColumn<String> column, String serverOrScopeId, String itemId) {
|
|
final scope = _splitScope(serverOrScopeId);
|
|
final escapedItemId = _escapeLike(itemId);
|
|
final scopedUser = scope.userId == null ? '%' : _escapeLike(scope.userId!);
|
|
final scopedPattern = '${_escapeLike(serverOrScopeId)}:/Users/$scopedUser/Items/$escapedItemId';
|
|
var predicate = column.like(scopedPattern, escapeChar: _likeEscape);
|
|
|
|
if (scope.userId == null) {
|
|
final compoundPattern = '${_escapeLike(scope.machineId)}/%:/Users/%/Items/$escapedItemId';
|
|
predicate = predicate | column.like(compoundPattern, escapeChar: _likeEscape);
|
|
} else {
|
|
final legacyPattern = '${_escapeLike(scope.machineId)}:/Users/${_escapeLike(scope.userId!)}/Items/$escapedItemId';
|
|
predicate = predicate | column.like(legacyPattern, escapeChar: _likeEscape);
|
|
}
|
|
return predicate;
|
|
}
|
|
|
|
Future<JellyfinCacheItem?> findItem(String serverOrScopeId, String itemId) async {
|
|
final matches = await _findItems(serverOrScopeId, itemId);
|
|
return matches.isEmpty ? null : matches.first;
|
|
}
|
|
|
|
Future<ResolvedJellyfinCacheItem?> findResolvedItem(String serverOrScopeId, String itemId) async {
|
|
final matches = await _findItems(serverOrScopeId, itemId);
|
|
for (final match in matches) {
|
|
final connection = await findConnection(match.key.scopeId, userId: match.key.userId);
|
|
if (connection != null) return (cacheRow: match.cacheRow, connection: connection, key: match.key);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
Future<List<JellyfinCacheItem>> _findItems(String serverOrScopeId, String itemId) async {
|
|
final rows =
|
|
await (database.select(database.apiCache)
|
|
..where((t) => itemKeyPredicate(t.cacheKey, serverOrScopeId, itemId))
|
|
..orderBy([(t) => OrderingTerm.asc(t.cacheKey)]))
|
|
.get();
|
|
final requested = _splitScope(serverOrScopeId);
|
|
final matches = <JellyfinCacheItem>[];
|
|
for (final row in rows) {
|
|
final key = parseItemKey(row.cacheKey);
|
|
if (key == null || key.itemId != itemId) continue;
|
|
if (key.machineId != requested.machineId) continue;
|
|
if (requested.userId != null && key.userId != requested.userId) continue;
|
|
matches.add((cacheRow: row, key: key));
|
|
}
|
|
if (requested.userId != null) {
|
|
matches.sort((a, b) => a.key.scopeId == serverOrScopeId ? -1 : (b.key.scopeId == serverOrScopeId ? 1 : 0));
|
|
}
|
|
return matches;
|
|
}
|
|
|
|
Future<List<ResolvedJellyfinCacheItem>> findPinnedItems() async {
|
|
final rows =
|
|
await (database.select(database.apiCache)
|
|
..where((t) => t.pinned.equals(true))
|
|
..orderBy([(t) => OrderingTerm.asc(t.cacheKey)]))
|
|
.get();
|
|
if (rows.isEmpty) return const [];
|
|
|
|
final connections = await (database.select(database.connections)..where((t) => _mediaBrowserKind(t.kind))).get();
|
|
final connectionById = {for (final connection in connections) connection.id: connection};
|
|
final bindings = await database.select(database.profileConnections).get();
|
|
final bindingsByConnection = <String, List<ProfileConnectionRow>>{};
|
|
for (final binding in bindings) {
|
|
bindingsByConnection.putIfAbsent(binding.connectionId, () => []).add(binding);
|
|
}
|
|
|
|
bool matchesBinding(String connectionId, String userId) {
|
|
final connectionBindings = bindingsByConnection[connectionId];
|
|
return connectionBindings == null ||
|
|
connectionBindings.isEmpty ||
|
|
connectionBindings.any((binding) => binding.userIdentifier == userId);
|
|
}
|
|
|
|
final matches = <ResolvedJellyfinCacheItem>[];
|
|
for (final row in rows) {
|
|
final key = parseItemKey(row.cacheKey);
|
|
if (key == null) continue;
|
|
final compoundId = '${key.machineId}/${key.userId}';
|
|
final compound = connectionById[compoundId];
|
|
if (compound != null && matchesBinding(compound.id, key.userId)) {
|
|
matches.add((cacheRow: row, connection: compound, key: key));
|
|
continue;
|
|
}
|
|
final legacy = connectionById[key.machineId];
|
|
if (legacy != null && matchesBinding(legacy.id, key.userId)) {
|
|
matches.add((cacheRow: row, connection: legacy, key: key));
|
|
}
|
|
}
|
|
return matches;
|
|
}
|
|
|
|
/// Resolves the exact persisted MediaBrowser cache namespace owned by
|
|
/// [profileId] for [serverOrScopeId].
|
|
///
|
|
/// The physical download row is deliberately not consulted: it is shared
|
|
/// across profiles and may have been created by a different MediaBrowser user.
|
|
Future<String?> findProfileScopeId(String serverOrScopeId, String profileId) async {
|
|
if (profileId.isEmpty) return null;
|
|
final requested = _splitScope(serverOrScopeId);
|
|
final bindings =
|
|
await (database.select(database.profileConnections)
|
|
..where((t) => t.profileId.equals(profileId))
|
|
..orderBy([
|
|
(t) => OrderingTerm.desc(t.isDefault),
|
|
(t) => OrderingTerm.desc(t.lastUsedAt),
|
|
(t) => OrderingTerm.asc(t.connectionId),
|
|
]))
|
|
.get();
|
|
for (final binding in bindings) {
|
|
if (binding.userIdentifier.isEmpty) continue;
|
|
final connection = await (database.select(
|
|
database.connections,
|
|
)..where((t) => t.id.equals(binding.connectionId) & _mediaBrowserKind(t.kind))).getSingleOrNull();
|
|
if (connection == null) continue;
|
|
|
|
final connectionScope = _splitScope(connection.id);
|
|
var machineId = connectionScope.machineId;
|
|
String? configuredUserId = connectionScope.userId;
|
|
try {
|
|
final config = jsonDecode(connection.configJson);
|
|
if (config is Map<String, dynamic>) {
|
|
final configuredMachineId = config['serverMachineId'];
|
|
final configuredUser = config['userId'];
|
|
if (configuredMachineId is String && configuredMachineId.isNotEmpty) {
|
|
machineId = configuredMachineId;
|
|
}
|
|
if (configuredUser is String && configuredUser.isNotEmpty) {
|
|
configuredUserId = configuredUser;
|
|
}
|
|
}
|
|
} on FormatException {
|
|
// Legacy rows can still be resolved from their canonical id.
|
|
}
|
|
if (machineId != requested.machineId) continue;
|
|
if (configuredUserId != null && configuredUserId != binding.userIdentifier) continue;
|
|
return '$machineId/${binding.userIdentifier}';
|
|
}
|
|
return null;
|
|
}
|
|
|
|
Future<ConnectionRow?> findConnection(String serverOrScopeId, {String? userId}) async {
|
|
final scope = _splitScope(serverOrScopeId);
|
|
if (scope.userId != null && userId != null && scope.userId != userId) return null;
|
|
final expectedUserId = userId ?? scope.userId;
|
|
|
|
if (expectedUserId != null) {
|
|
final compoundId = '${scope.machineId}/$expectedUserId';
|
|
final compound = await (database.select(
|
|
database.connections,
|
|
)..where((t) => t.id.equals(compoundId) & _mediaBrowserKind(t.kind))).getSingleOrNull();
|
|
if (compound != null && await _matchesProfileBinding(compound.id, expectedUserId)) return compound;
|
|
|
|
final legacy = await (database.select(
|
|
database.connections,
|
|
)..where((t) => t.id.equals(scope.machineId) & _mediaBrowserKind(t.kind))).getSingleOrNull();
|
|
if (legacy != null && await _matchesProfileBinding(legacy.id, expectedUserId)) return legacy;
|
|
return null;
|
|
}
|
|
|
|
final exact = await (database.select(
|
|
database.connections,
|
|
)..where((t) => t.id.equals(scope.machineId))).getSingleOrNull();
|
|
if (exact != null) return exact;
|
|
|
|
final plex = await _findPlexConnectionForServer(scope.machineId);
|
|
if (plex != null) return plex;
|
|
|
|
final prefix = '${scope.machineId}/';
|
|
return (database.select(database.connections)
|
|
..where((t) => t.id.substr(1, prefix.length).equals(prefix) & _mediaBrowserKind(t.kind))
|
|
..orderBy([(t) => OrderingTerm.asc(t.id)])
|
|
..limit(1))
|
|
.getSingleOrNull();
|
|
}
|
|
|
|
Future<ConnectionRow?> _findPlexConnectionForServer(String serverId) async {
|
|
final accounts = await (database.select(database.connections)..where((t) => t.kind.equals('plex'))).get();
|
|
for (final account in accounts) {
|
|
try {
|
|
final config = jsonDecode(account.configJson);
|
|
if (config is! Map<String, dynamic>) continue;
|
|
final servers = config['servers'];
|
|
if (servers is! List) continue;
|
|
for (final server in servers) {
|
|
if (server is Map && server['clientIdentifier'] == serverId) {
|
|
return account;
|
|
}
|
|
}
|
|
} on FormatException {
|
|
// Ignore malformed persisted accounts and continue deterministically.
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
Future<bool> _matchesProfileBinding(String connectionId, String userId) async {
|
|
final bindings = await (database.select(
|
|
database.profileConnections,
|
|
)..where((t) => t.connectionId.equals(connectionId))).get();
|
|
return bindings.isEmpty || bindings.any((binding) => binding.userIdentifier == userId);
|
|
}
|
|
|
|
static JellyfinItemCacheKey? parseItemKey(String cacheKey) {
|
|
final usersMarker = cacheKey.indexOf(_usersMarker);
|
|
if (usersMarker <= 0) return null;
|
|
final scopeId = cacheKey.substring(0, usersMarker);
|
|
final userStart = usersMarker + _usersMarker.length;
|
|
final itemsMarker = cacheKey.indexOf(_itemsMarker, userStart);
|
|
if (itemsMarker <= userStart) return null;
|
|
final userId = cacheKey.substring(userStart, itemsMarker);
|
|
final itemId = cacheKey.substring(itemsMarker + _itemsMarker.length);
|
|
if (itemId.isEmpty || itemId.contains('/') || itemId.contains('?')) return null;
|
|
|
|
final scope = _splitScope(scopeId);
|
|
if (scope.userId != null && scope.userId != userId) return null;
|
|
return (scopeId: scopeId, machineId: scope.machineId, userId: userId, itemId: itemId);
|
|
}
|
|
|
|
static ({String machineId, String? userId}) _splitScope(String scopeId) {
|
|
final slash = scopeId.indexOf('/');
|
|
if (slash <= 0 || slash == scopeId.length - 1) return (machineId: scopeId, userId: null);
|
|
return (machineId: scopeId.substring(0, slash), userId: scopeId.substring(slash + 1));
|
|
}
|
|
|
|
static String _escapeLike(String value) {
|
|
return value.replaceAll(_likeEscape, '$_likeEscape$_likeEscape').replaceAll('%', r'\%').replaceAll('_', r'\_');
|
|
}
|
|
}
|