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.
294 lines
12 KiB
Dart
294 lines
12 KiB
Dart
import 'dart:convert';
|
|
import '../media/ids.dart';
|
|
|
|
import 'package:drift/drift.dart';
|
|
|
|
import '../database/app_database.dart';
|
|
import '../media/media_backend.dart';
|
|
import '../media/media_browser_dialect.dart';
|
|
import '../media/media_item.dart';
|
|
import '../utils/app_logger.dart';
|
|
import '../utils/global_key_utils.dart';
|
|
import '../utils/isolate_helper.dart';
|
|
import 'api_cache.dart';
|
|
import 'credential_vault.dart';
|
|
import 'jellyfin_cache_resolver.dart';
|
|
import 'jellyfin_mappers.dart';
|
|
|
|
/// MediaBrowser-shape helpers on top of the shared [ApiCache] substrate.
|
|
///
|
|
/// Cache rows for Jellyfin and Emby item metadata use the compound connection
|
|
/// id (`{machineId}/{userId}`) plus the read-path endpoint key
|
|
/// `/Users/{userId}/Items/{itemId}`. The public [MediaItem.serverId] remains
|
|
/// the bare machine id; the compound prefix only isolates local user-scoped
|
|
/// state such as `UserData`.
|
|
class JellyfinApiCache extends ApiCache {
|
|
static final _singleton = ApiCacheSingleton<JellyfinApiCache>(const {
|
|
MediaBackend.jellyfin,
|
|
MediaBackend.emby,
|
|
}, 'JellyfinApiCache');
|
|
static JellyfinApiCache get instance => _singleton.instance;
|
|
|
|
JellyfinApiCache._(super.db);
|
|
|
|
/// Initialize the singleton with an [AppDatabase] instance. Also registers
|
|
/// this instance with the [ApiCache] backend dispatch so callers using
|
|
/// `ApiCache.forBackend(MediaBackend.jellyfin)` or
|
|
/// `ApiCache.forBackend(MediaBackend.emby)` resolve here.
|
|
static void initialize(AppDatabase db) => _singleton.install(JellyfinApiCache._(db));
|
|
|
|
JellyfinCacheResolver get _resolver => JellyfinCacheResolver(database);
|
|
|
|
static String mediaSegmentsEndpoint(String itemId) => '/MediaSegments/${Uri.encodeComponent(itemId)}';
|
|
|
|
/// Delete cached item metadata and playback segment rows for [itemId].
|
|
/// Children-list endpoints are out of scope for v1 — they'll get cleaned up
|
|
/// via [deleteForServer] or [clearAll].
|
|
@override
|
|
Future<void> deleteForItem(ServerId serverId, String itemId) async {
|
|
final endpoint = mediaSegmentsEndpoint(itemId);
|
|
await (database.delete(database.apiCache)..where(
|
|
(t) => _resolver.itemKeyPredicate(t.cacheKey, serverId, itemId) | t.cacheKey.equals('$serverId:$endpoint'),
|
|
))
|
|
.go();
|
|
}
|
|
|
|
/// Pin the metadata row(s) for [itemId] so they survive cache eviction.
|
|
@override
|
|
Future<void> pinForOffline(ServerId serverId, String itemId) async {
|
|
final endpoint = mediaSegmentsEndpoint(itemId);
|
|
await Future.wait([
|
|
(database.update(database.apiCache)..where((t) => _resolver.itemKeyPredicate(t.cacheKey, serverId, itemId)))
|
|
.write(const ApiCacheCompanion(pinned: Value(true))),
|
|
pin(serverId, endpoint),
|
|
]);
|
|
}
|
|
|
|
Future<void> unpinForOffline(ServerId serverId, String itemId) async {
|
|
final endpoint = mediaSegmentsEndpoint(itemId);
|
|
await Future.wait([
|
|
(database.update(database.apiCache)..where((t) => _resolver.itemKeyPredicate(t.cacheKey, serverId, itemId)))
|
|
.write(const ApiCacheCompanion(pinned: Value(false))),
|
|
unpin(serverId, endpoint),
|
|
]);
|
|
}
|
|
|
|
/// Whether the metadata for [itemId] is pinned for offline.
|
|
///
|
|
/// Named `isPinnedItemId` to avoid colliding with the inherited
|
|
/// [ApiCache.isPinned]'s identical Dart signature.
|
|
Future<bool> isPinnedItemId(ServerId serverId, String itemId) async {
|
|
final row =
|
|
await (database.select(database.apiCache)
|
|
..where((t) => _resolver.itemKeyPredicate(t.cacheKey, serverId, itemId) & t.pinned.equals(true))
|
|
..limit(1))
|
|
.getSingleOrNull();
|
|
return row != null;
|
|
}
|
|
|
|
/// Fetch and parse a [MediaItem] from cache.
|
|
///
|
|
/// Returns `null` when no matching row is cached, the row's JSON is
|
|
/// unparseable, or the [Connections] row for [serverId] is missing/has no
|
|
/// usable `baseUrl`.
|
|
///
|
|
/// Image paths are run through [JellyfinImageAbsolutizer] so cached items
|
|
/// carry the same absolute URLs as items produced by [JellyfinClient]'s
|
|
/// live mapper boundary — without this, downstream consumers (artwork
|
|
/// downloads, offline image rendering) see raw `/Items/...` paths and
|
|
/// fail.
|
|
///
|
|
/// Single-item path stays on the main isolate — decoding one BaseItemDto
|
|
/// is cheap and matches [PlexApiCache.getMetadata]'s shape. Bulk-load
|
|
/// callers go through [getAllPinnedMetadata] which still parallelises.
|
|
@override
|
|
Future<MediaItem?> getMetadata(ServerId serverId, String itemId) async {
|
|
final resolved = await _resolver.findResolvedItem(serverId, itemId);
|
|
if (resolved == null) return null;
|
|
final ctx = await _serverContext(resolved.connection, machineId: resolved.key.machineId);
|
|
if (ctx == null) return null;
|
|
|
|
try {
|
|
final data = jsonDecode(resolved.cacheRow.data) as Map<String, dynamic>;
|
|
final absolutizer = JellyfinImageAbsolutizer(baseUrl: ctx.baseUrl, accessToken: ctx.accessToken);
|
|
return JellyfinMappers.mediaItem(
|
|
data,
|
|
serverId: ServerId(ctx.machineId),
|
|
serverName: ctx.name,
|
|
absolutizer: absolutizer,
|
|
dialect: ctx.dialect,
|
|
);
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// Persist a watched/unwatched flip into cached `BaseItemDto` rows for
|
|
/// [itemId]. Compound MediaBrowser scope ids update only their user. A legacy
|
|
/// bare machine id is accepted only when its matching rows belong to one
|
|
/// user; ambiguous multi-user writes are skipped rather than bleeding watch
|
|
/// state across profiles.
|
|
///
|
|
/// [viewOffsetMs] is converted to MediaBrowser 100-ns ticks for
|
|
/// `UserData.PlaybackPositionTicks`. [lastViewedAt] is treated as Plex's
|
|
/// epoch-seconds and translated to the MediaBrowser ISO-8601
|
|
/// `LastPlayedDate`. [viewedLeafCount] is ignored — both dialects compute
|
|
/// per-show rollup from individual children via `UserData.UnplayedItemCount`.
|
|
/// The parameter is accepted for API parity with the Plex caller.
|
|
@override
|
|
Future<void> applyWatchState({
|
|
required ServerId serverId,
|
|
required String itemId,
|
|
required bool isWatched,
|
|
int? viewOffsetMs,
|
|
int? lastViewedAt,
|
|
int? viewedLeafCount,
|
|
}) async {
|
|
final query = database.select(database.apiCache)
|
|
..where((t) => _resolver.itemKeyPredicate(t.cacheKey, serverId, itemId));
|
|
final rows = await query.get();
|
|
if (rows.isEmpty) return;
|
|
if (!serverId.contains('/')) {
|
|
final userIds = <String>{
|
|
for (final row in rows)
|
|
if (JellyfinCacheResolver.parseItemKey(row.cacheKey) case final key?) key.userId,
|
|
};
|
|
if (userIds.length > 1) {
|
|
appLogger.w(
|
|
'Skipping ambiguous bare-scope MediaBrowser watch-state cache write',
|
|
error: {'serverId': serverId, 'itemId': itemId, 'userCount': userIds.length},
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
for (final row in rows) {
|
|
try {
|
|
final data = jsonDecode(row.data) as Map<String, dynamic>;
|
|
final userData = (data['UserData'] is Map<String, dynamic>)
|
|
? (data['UserData'] as Map<String, dynamic>)
|
|
: <String, dynamic>{};
|
|
userData['Played'] = isWatched;
|
|
final positionTicks = viewOffsetMs != null ? viewOffsetMs * 10_000 : 0;
|
|
if (isWatched) {
|
|
final current = (userData['PlayCount'] as num?)?.toInt() ?? 0;
|
|
userData['PlayCount'] = current < 1 ? 1 : current;
|
|
userData['PlaybackPositionTicks'] = positionTicks;
|
|
userData['LastPlayedDate'] = lastViewedAt != null
|
|
? DateTime.fromMillisecondsSinceEpoch(lastViewedAt * 1000, isUtc: true).toIso8601String()
|
|
: DateTime.now().toUtc().toIso8601String();
|
|
} else {
|
|
userData['PlayCount'] = 0;
|
|
userData['PlaybackPositionTicks'] = positionTicks;
|
|
if (lastViewedAt != null) {
|
|
userData['LastPlayedDate'] = DateTime.fromMillisecondsSinceEpoch(
|
|
lastViewedAt * 1000,
|
|
isUtc: true,
|
|
).toIso8601String();
|
|
}
|
|
}
|
|
data['UserData'] = userData;
|
|
final encoded = jsonEncode(data);
|
|
await (database.update(database.apiCache)..where((t) => t.cacheKey.equals(row.cacheKey))).write(
|
|
ApiCacheCompanion(data: Value(encoded), cachedAt: Value(DateTime.now())),
|
|
);
|
|
} catch (_) {
|
|
// Skip malformed entries.
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Load all pinned MediaBrowser metadata in a single query.
|
|
///
|
|
/// Returns a map keyed by `buildGlobalKey(ServerId(serverId), itemId)` for O(1)
|
|
/// lookups, mirroring [PlexApiCache.getAllPinnedMetadata] so callers can
|
|
/// spread-merge the two results.
|
|
@override
|
|
Future<Map<String, MediaItem>> getAllPinnedMetadata({Set<ServerId>? cacheServerIds}) async {
|
|
final allEntries = await _resolver.findPinnedItems();
|
|
final entries = cacheServerIds == null
|
|
? allEntries
|
|
: allEntries
|
|
.where(
|
|
(entry) =>
|
|
cacheServerIds.contains(ServerId(entry.key.scopeId)) ||
|
|
cacheServerIds.contains(ServerId('${entry.key.machineId}/${entry.key.userId}')),
|
|
)
|
|
.toList(growable: false);
|
|
if (entries.isEmpty) return {};
|
|
|
|
// Resolve the connection context per serverId once on the main thread
|
|
// (DB queries can't move into the isolate). Each context carries the
|
|
// serverName and dialect used to stamp the [MediaItem] plus the
|
|
// baseUrl/accessToken required to absolutize image paths.
|
|
final contexts =
|
|
<String, ({String machineId, String name, String baseUrl, String accessToken, MediaBrowserDialect dialect})>{};
|
|
final absolutizers = <String, JellyfinImageAbsolutizer>{};
|
|
for (final entry in entries) {
|
|
final id = entry.connection.id;
|
|
if (contexts.containsKey(id)) continue;
|
|
final ctx = await _serverContext(entry.connection, machineId: entry.key.machineId);
|
|
if (ctx != null) {
|
|
contexts[id] = ctx;
|
|
absolutizers[id] = JellyfinImageAbsolutizer(baseUrl: ctx.baseUrl, accessToken: ctx.accessToken);
|
|
}
|
|
}
|
|
|
|
return await tryIsolateRun(
|
|
() => decodeCachedMediaRows(
|
|
entries,
|
|
serializedData: (entry) => entry.cacheRow.data,
|
|
decode: (entry, data) {
|
|
final ctx = contexts[entry.connection.id];
|
|
final absolutizer = absolutizers[entry.connection.id];
|
|
if (ctx == null || absolutizer == null) return null;
|
|
final mapped = JellyfinMappers.mediaItem(
|
|
data,
|
|
serverId: ServerId(ctx.machineId),
|
|
serverName: ctx.name,
|
|
absolutizer: absolutizer,
|
|
dialect: ctx.dialect,
|
|
);
|
|
if (mapped == null) return null;
|
|
return MapEntry(buildGlobalKey(ServerId(entry.key.scopeId), entry.key.itemId), mapped);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Resolve the connection context (server name, dialect, base URL, and access
|
|
/// token) for a cache row keyed by the server's machineId. The [Connections]
|
|
/// row's `id` is `${serverMachineId}/$userId`, so a direct `id == serverId`
|
|
/// lookup misses; fall back to a prefix match.
|
|
///
|
|
/// `name` and `dialect` match what the live [JellyfinClient] stamps onto
|
|
/// online MediaItems. `baseUrl` and `accessToken` come from the same
|
|
/// `configJson` payload [JellyfinConnection.toConfigJson] writes, so
|
|
/// cache-read absolutization uses the current values — token/URL rotations
|
|
/// Just Work.
|
|
///
|
|
/// Returns `null` when no row matches or the row carries an empty `baseUrl`
|
|
/// (no honest URL we can build).
|
|
Future<({String machineId, String name, String baseUrl, String accessToken, MediaBrowserDialect dialect})?>
|
|
_serverContext(ConnectionRow row, {required String machineId}) async {
|
|
String? configName;
|
|
String? configMachineId;
|
|
String baseUrl = '';
|
|
String accessToken = '';
|
|
final dialect = MediaBrowserDialect.fromIdOrJellyfin(row.kind);
|
|
try {
|
|
final rawConfig = jsonDecode(row.configJson) as Map<String, dynamic>;
|
|
final config = (await CredentialVault.revealConnectionConfig(row.kind, rawConfig)).config;
|
|
configName = config['serverName'] as String?;
|
|
configMachineId = config['serverMachineId'] as String?;
|
|
baseUrl = config['baseUrl'] as String? ?? '';
|
|
accessToken = config['accessToken'] as String? ?? '';
|
|
} catch (_) {
|
|
// Fall through with the values defaulted above.
|
|
}
|
|
if (baseUrl.isEmpty) return null;
|
|
configMachineId ??= machineId;
|
|
final name = (configName != null && configName.isNotEmpty) ? configName : row.displayName;
|
|
return (machineId: configMachineId, name: name, baseUrl: baseUrl, accessToken: accessToken, dialect: dialect);
|
|
}
|
|
}
|