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.
218 lines
7.5 KiB
Dart
218 lines
7.5 KiB
Dart
import '../../media/library_first_character.dart';
|
||
import '../../media/media_backend.dart';
|
||
import '../../services/plex_client.dart';
|
||
import 'alpha_jump_helper.dart';
|
||
|
||
/// Backend-specific alpha-jump-bar behaviour.
|
||
///
|
||
/// Plex libraries use real per-letter counts from
|
||
/// `/library/sections/{id}/firstCharacter`, so the bar is scroll-position
|
||
/// driven — tapping a letter scrolls to that letter's cumulative offset and
|
||
/// the highlighted letter follows the visible row.
|
||
///
|
||
/// MediaBrowser libraries have no per-letter count endpoint. The bar
|
||
/// synthesises the 27-letter alphabet (`#`, `A`–`Z`) and acts as a name-prefix
|
||
/// filter that refetches the page when the user picks a letter (matching the
|
||
/// server web clients' UX).
|
||
abstract class LibraryAlphaBarStrategy {
|
||
/// Whether the bar should be rendered at all. Implementations consider
|
||
/// total item count, sort key, and current filter state.
|
||
bool shouldShow({
|
||
required int totalItemCount,
|
||
required int loadedCharacterCount,
|
||
required String? sortKey,
|
||
required bool isFolderGrouping,
|
||
required String? mediaBrowserAlphaPrefix,
|
||
required bool isPhone,
|
||
});
|
||
|
||
/// Load the first-character buckets for the current filter state.
|
||
/// Returns the new helper plus the synthesised character list — the caller
|
||
/// stores both in widget state.
|
||
Future<({List<LibraryFirstCharacter> chars, AlphaJumpHelper helper})> loadCharacters({
|
||
required Map<String, String> filters,
|
||
required int? typeId,
|
||
required bool descending,
|
||
});
|
||
|
||
/// Letter to highlight given the current scroll-derived index. Plex maps
|
||
/// the index back through the cumulative offsets; MediaBrowser backends echo
|
||
/// back whatever filter is active.
|
||
String currentLetter(int index, AlphaJumpHelper helper, {String? mediaBrowserAlphaPrefix});
|
||
|
||
/// Handle a tap on the letter at [targetIndex]. Plex strategies invoke
|
||
/// [onPlexJump] with the cumulative item index for in-grid scrolling;
|
||
/// MediaBrowser strategies invoke [onMediaBrowserPrefixChange] with the next
|
||
/// `NameStartsWith` prefix (or `null` to clear the filter when the user
|
||
/// re-taps the active letter). Each strategy ignores the callback that
|
||
/// doesn't apply to its UX, so callers can wire both unconditionally.
|
||
void onLetterPressed(
|
||
int targetIndex,
|
||
AlphaJumpHelper helper, {
|
||
required String? currentMediaBrowserPrefix,
|
||
required void Function(int index) onPlexJump,
|
||
required void Function(String? nextPrefix) onMediaBrowserPrefixChange,
|
||
});
|
||
|
||
/// Construct the right strategy for [backend].
|
||
factory LibraryAlphaBarStrategy.forBackend(
|
||
MediaBackend backend, {
|
||
required PlexClient Function() plexClientProvider,
|
||
required String libraryKey,
|
||
required bool isShared,
|
||
}) {
|
||
return switch (backend) {
|
||
MediaBackend.plex => PlexAlphaBarStrategy(
|
||
plexClientProvider: plexClientProvider,
|
||
libraryKey: libraryKey,
|
||
isShared: isShared,
|
||
),
|
||
MediaBackend.jellyfin || MediaBackend.emby => const MediaBrowserAlphaBarStrategy(),
|
||
};
|
||
}
|
||
}
|
||
|
||
/// Plex strategy — calls `/library/sections/{id}/firstCharacter` for real
|
||
/// per-letter counts and uses the cumulative offsets to drive scroll
|
||
/// position.
|
||
class PlexAlphaBarStrategy implements LibraryAlphaBarStrategy {
|
||
final PlexClient Function() plexClientProvider;
|
||
final String libraryKey;
|
||
final bool isShared;
|
||
|
||
PlexAlphaBarStrategy({required this.plexClientProvider, required this.libraryKey, required this.isShared});
|
||
|
||
@override
|
||
bool shouldShow({
|
||
required int totalItemCount,
|
||
required int loadedCharacterCount,
|
||
required String? sortKey,
|
||
required bool isFolderGrouping,
|
||
required String? mediaBrowserAlphaPrefix,
|
||
required bool isPhone,
|
||
}) {
|
||
if (isFolderGrouping) return false;
|
||
if (loadedCharacterCount < 6 || totalItemCount < 80) return false;
|
||
final s = sortKey ?? '';
|
||
return s.isEmpty || s.startsWith('titleSort');
|
||
}
|
||
|
||
@override
|
||
Future<({List<LibraryFirstCharacter> chars, AlphaJumpHelper helper})> loadCharacters({
|
||
required Map<String, String> filters,
|
||
required int? typeId,
|
||
required bool descending,
|
||
}) async {
|
||
if (isShared) {
|
||
// Shared libraries don't support first-characters.
|
||
return (chars: const <LibraryFirstCharacter>[], helper: AlphaJumpHelper(const []));
|
||
}
|
||
final client = plexClientProvider();
|
||
final params = Map<String, String>.from(filters);
|
||
params['includeCollections'] = '1';
|
||
final chars = await client.getFirstCharacters(libraryKey, type: typeId, filters: params.isNotEmpty ? params : null);
|
||
return (chars: chars, helper: AlphaJumpHelper(chars, descending: descending));
|
||
}
|
||
|
||
@override
|
||
String currentLetter(int index, AlphaJumpHelper helper, {String? mediaBrowserAlphaPrefix}) =>
|
||
helper.currentLetter(index);
|
||
|
||
/// Plex jumps the grid to the cumulative offset for the tapped letter —
|
||
/// the helper's letter list already encodes the per-letter ranges from
|
||
/// the server's `/firstCharacter` counts.
|
||
@override
|
||
void onLetterPressed(
|
||
int targetIndex,
|
||
AlphaJumpHelper helper, {
|
||
required String? currentMediaBrowserPrefix,
|
||
required void Function(int index) onPlexJump,
|
||
required void Function(String? nextPrefix) onMediaBrowserPrefixChange,
|
||
}) {
|
||
onPlexJump(targetIndex);
|
||
}
|
||
}
|
||
|
||
/// MediaBrowser strategy — synthesises the 27-letter alphabet locally and
|
||
/// uses the bar as a `NameStartsWith` filter.
|
||
class MediaBrowserAlphaBarStrategy implements LibraryAlphaBarStrategy {
|
||
static const _letters = [
|
||
'#',
|
||
'A',
|
||
'B',
|
||
'C',
|
||
'D',
|
||
'E',
|
||
'F',
|
||
'G',
|
||
'H',
|
||
'I',
|
||
'J',
|
||
'K',
|
||
'L',
|
||
'M',
|
||
'N',
|
||
'O',
|
||
'P',
|
||
'Q',
|
||
'R',
|
||
'S',
|
||
'T',
|
||
'U',
|
||
'V',
|
||
'W',
|
||
'X',
|
||
'Y',
|
||
'Z',
|
||
];
|
||
|
||
const MediaBrowserAlphaBarStrategy();
|
||
|
||
@override
|
||
bool shouldShow({
|
||
required int totalItemCount,
|
||
required int loadedCharacterCount,
|
||
required String? sortKey,
|
||
required bool isFolderGrouping,
|
||
required String? mediaBrowserAlphaPrefix,
|
||
required bool isPhone,
|
||
}) {
|
||
if (isPhone) return false;
|
||
if (isFolderGrouping) return false;
|
||
if (loadedCharacterCount == 0) return false;
|
||
return totalItemCount >= 80 || mediaBrowserAlphaPrefix != null;
|
||
}
|
||
|
||
@override
|
||
Future<({List<LibraryFirstCharacter> chars, AlphaJumpHelper helper})> loadCharacters({
|
||
required Map<String, String> filters,
|
||
required int? typeId,
|
||
required bool descending,
|
||
}) async {
|
||
final synthetic = [for (final l in _letters) LibraryFirstCharacter(key: l, title: l, size: 1)];
|
||
return (chars: synthetic, helper: AlphaJumpHelper(synthetic, descending: descending));
|
||
}
|
||
|
||
@override
|
||
String currentLetter(int index, AlphaJumpHelper helper, {String? mediaBrowserAlphaPrefix}) =>
|
||
mediaBrowserAlphaPrefix ?? '';
|
||
|
||
/// MediaBrowser backends reuse the alpha bar as a `NameStartsWith` filter.
|
||
/// We map the bar offset back to a letter (the synthesised `size: 1` entries
|
||
/// make offset == position in [helper.letters]) and toggle the filter —
|
||
/// re-tap the active letter to clear, otherwise set the new prefix.
|
||
@override
|
||
void onLetterPressed(
|
||
int targetIndex,
|
||
AlphaJumpHelper helper, {
|
||
required String? currentMediaBrowserPrefix,
|
||
required void Function(int index) onPlexJump,
|
||
required void Function(String? nextPrefix) onMediaBrowserPrefixChange,
|
||
}) {
|
||
if (targetIndex < 0 || targetIndex >= helper.letters.length) return;
|
||
final letter = helper.letters[targetIndex];
|
||
final next = (currentMediaBrowserPrefix == letter) ? null : letter;
|
||
onMediaBrowserPrefixChange(next);
|
||
}
|
||
}
|