Files
plezy/lib/screens/libraries/library_alpha_bar_strategy.dart
T
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

218 lines
7.5 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}