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.
244 lines
8.9 KiB
Dart
244 lines
8.9 KiB
Dart
part of '../../jellyfin_client.dart';
|
|
|
|
mixin _JellyfinPlaylistMethods on _JellyfinClientInternals {
|
|
static const int _playlistsPageSize = 200;
|
|
|
|
@override
|
|
Future<List<MediaPlaylist>> fetchPlaylists({String playlistType = 'video', bool? smart}) => drainPages<MediaPlaylist>(
|
|
(start, size) => fetchPlaylistsPage(playlistType: playlistType, smart: smart, start: start, size: size),
|
|
pageSize: _playlistsPageSize,
|
|
);
|
|
|
|
@override
|
|
Future<LibraryPage<MediaPlaylist>> fetchPlaylistsPage({
|
|
String playlistType = 'video',
|
|
bool? smart,
|
|
int? start,
|
|
int? size,
|
|
AbortController? abort,
|
|
}) async {
|
|
if (smart == true) {
|
|
return LibraryPage<MediaPlaylist>(items: const [], totalCount: 0, offset: start ?? 0);
|
|
}
|
|
|
|
final offset = start ?? 0;
|
|
final pageSize = size ?? _playlistsPageSize;
|
|
final requestedType = playlistType.toLowerCase();
|
|
final mediaType = switch (requestedType) {
|
|
'' => null,
|
|
'video' => 'Video',
|
|
'audio' => 'Audio',
|
|
'photo' => 'Photo',
|
|
'book' => 'Book',
|
|
'unknown' => 'Unknown',
|
|
_ => '',
|
|
};
|
|
if (mediaType == '') {
|
|
return LibraryPage<MediaPlaylist>(items: const [], totalCount: 0, offset: offset);
|
|
}
|
|
|
|
// Emby returns the entire item index when `/Items` carries a `MediaTypes`
|
|
// filter alongside `IncludeItemTypes=Playlist`, and never types a playlist
|
|
// DTO, so it lists every playlist and the requested type only decides how
|
|
// the results are labelled. See
|
|
// [MediaBrowserDialect.playlistsFilterByMediaType].
|
|
final filterByMediaType = dialect.playlistsFilterByMediaType;
|
|
final labelType = requestedType.isEmpty ? 'video' : requestedType;
|
|
|
|
final response = await _http.get(
|
|
'/Items',
|
|
queryParameters: {
|
|
'userId': connection.userId,
|
|
'IncludeItemTypes': 'Playlist',
|
|
'Recursive': 'true',
|
|
'MediaTypes': ?(filterByMediaType ? mediaType : null),
|
|
'StartIndex': offset.toString(),
|
|
'Limit': pageSize.toString(),
|
|
// `DateModified` carries the modification time on Emby, which leaves
|
|
// `DateLastSaved` null for playlists. Unlike the detail route, the list
|
|
// route honours `Fields` strictly, so the mapper's fallback is dead
|
|
// unless the field is requested here.
|
|
'Fields': 'Overview,DateCreated,DateLastSaved,DateModified,ChildCount,Tags',
|
|
...jellyfinImageQueryParameters,
|
|
},
|
|
abort: abort,
|
|
);
|
|
throwIfHttpError(response);
|
|
return _pagedItems(
|
|
response.data,
|
|
offset: offset,
|
|
requestedSize: pageSize,
|
|
map: (raw) => raw.map((json) => _playlistFromJson(json, labelType: labelType)).toList(),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<MediaPlaylist?> fetchPlaylistMetadata(String id) async {
|
|
final item = await fetchItem(id);
|
|
if (item == null) return null;
|
|
return MediaPlaylist(
|
|
id: item.id,
|
|
backend: dialect.backend,
|
|
title: item.title ?? t.playlists.playlist,
|
|
summary: item.summary,
|
|
smart: false,
|
|
playlistType: _playlistMediaType(item),
|
|
durationMs: item.durationMs,
|
|
leafCount: item.leafCount,
|
|
thumbPath: item.thumbPath,
|
|
addedAt: item.addedAt,
|
|
updatedAt: item.updatedAt,
|
|
serverId: serverId,
|
|
serverName: serverName,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<List<MediaItem>> fetchPlaylistItems(String id, {int offset = 0, int limit = 100}) async {
|
|
final page = await fetchPlaylistPage(id, start: offset, size: limit);
|
|
return page.items;
|
|
}
|
|
|
|
@override
|
|
Future<LibraryPage<MediaItem>> fetchPlaylistPage(String id, {int? start, int? size, AbortController? abort}) async {
|
|
final offset = start ?? 0;
|
|
final pageSize = size ?? 100;
|
|
final response = await _http.get(
|
|
'/Playlists/${_segment(id)}/Items',
|
|
queryParameters: {
|
|
'userId': connection.userId,
|
|
'StartIndex': offset.toString(),
|
|
'Limit': pageSize.toString(),
|
|
'Fields': _browseFields,
|
|
...jellyfinImageQueryParameters,
|
|
},
|
|
abort: abort,
|
|
);
|
|
throwIfHttpError(response);
|
|
return _pagedItems(response.data, offset: offset, requestedSize: pageSize, map: _mapItems);
|
|
}
|
|
|
|
@override
|
|
Future<MediaPlaylist?> createPlaylist({required String title, required List<MediaItem> items}) async {
|
|
// MediaType stamps the playlist's kind server-side; derive it from the
|
|
// seed items so music selections create Audio playlists (which is what
|
|
// fetchPlaylistsPage filters on). Empty seeds keep the Video default.
|
|
final isMusic = items.isNotEmpty && items.first.kind.isMusic;
|
|
final response = await _http.post(
|
|
'/Playlists',
|
|
queryParameters: {
|
|
'Name': title,
|
|
'Ids': items.map((i) => i.id).join(','),
|
|
'UserId': connection.userId,
|
|
'MediaType': isMusic ? 'Audio' : 'Video',
|
|
},
|
|
);
|
|
throwIfHttpError(response);
|
|
final data = response.data;
|
|
final newId = data is Map<String, dynamic> ? data['Id'] as String? : null;
|
|
if (newId == null || newId.isEmpty) return null;
|
|
return fetchPlaylistMetadata(newId);
|
|
}
|
|
|
|
@override
|
|
Future<bool> addToPlaylist({required String playlistId, required List<MediaItem> items}) async {
|
|
if (items.isEmpty) return true;
|
|
final response = await _http.post(
|
|
'/Playlists/${_segment(playlistId)}/Items',
|
|
queryParameters: {'Ids': items.map((i) => i.id).join(','), 'UserId': connection.userId},
|
|
);
|
|
throwIfHttpError(response);
|
|
return true;
|
|
}
|
|
|
|
@override
|
|
Future<bool> deletePlaylist(MediaPlaylist playlist) async {
|
|
// Both MediaBrowser dialects treat playlists as items — same delete endpoint.
|
|
final response = await _http.delete('/Items/${_segment(playlist.id)}');
|
|
throwIfHttpError(response);
|
|
return true;
|
|
}
|
|
|
|
/// The MediaBrowser move endpoint takes an absolute index, so [afterItem] is
|
|
/// ignored — its sibling Plex impl needs it for `?after=`. The "wrong
|
|
/// backend" / "missing playlistItemId" branches still return `false`
|
|
/// (business not-applicable, not a network error) so callers can revert
|
|
/// optimistic UI changes; an HTTP error throws like the rest of the
|
|
/// write surface.
|
|
@override
|
|
Future<bool> movePlaylistItem({
|
|
required String playlistId,
|
|
required MediaItem item,
|
|
required int newIndex,
|
|
required MediaItem? afterItem,
|
|
}) async {
|
|
if (item is! JellyfinMediaItem) {
|
|
appLogger.e('movePlaylistItem: expected JellyfinMediaItem, got ${item.runtimeType} (id=${item.id})');
|
|
return false;
|
|
}
|
|
if (item.playlistItemId == null) {
|
|
appLogger.e('Jellyfin movePlaylistItem failed: missing playlist entry ID');
|
|
return false;
|
|
}
|
|
final response = await _http.post(
|
|
'/Playlists/${_segment(playlistId)}/Items/${_segment(item.playlistItemId!)}/Move/$newIndex',
|
|
);
|
|
throwIfHttpError(response);
|
|
return true;
|
|
}
|
|
|
|
@override
|
|
Future<bool> removeFromPlaylist({required String playlistId, required MediaItem item}) async {
|
|
if (item is! JellyfinMediaItem) {
|
|
appLogger.e('removeFromPlaylist: expected JellyfinMediaItem, got ${item.runtimeType} (id=${item.id})');
|
|
return false;
|
|
}
|
|
if (item.playlistItemId == null) {
|
|
appLogger.e('Jellyfin removeFromPlaylist failed: missing playlist entry ID');
|
|
return false;
|
|
}
|
|
final response = await _http.delete(
|
|
'/Playlists/${_segment(playlistId)}/Items',
|
|
queryParameters: {'entryIds': item.playlistItemId},
|
|
);
|
|
throwIfHttpError(response);
|
|
return true;
|
|
}
|
|
|
|
/// [labelType] backs `MediaType` when the server omits it — always the case
|
|
/// on Emby, which leaves playlists untyped.
|
|
MediaPlaylist _playlistFromJson(Map<String, dynamic> json, {String labelType = 'video'}) {
|
|
final id = json['Id'] as String? ?? '';
|
|
return MediaPlaylist(
|
|
id: id,
|
|
backend: dialect.backend,
|
|
title: json['Name'] as String? ?? t.playlists.playlist,
|
|
summary: json['Overview'] as String?,
|
|
smart: false,
|
|
playlistType: (json['MediaType'] as String?)?.toLowerCase() ?? labelType,
|
|
leafCount: json['ChildCount'] as int?,
|
|
addedAt: jellyfinIsoToEpochSeconds(json['DateCreated'] as String?),
|
|
// Emby leaves `DateLastSaved` null on a playlist and carries the
|
|
// timestamp in `DateModified`; the item and library mappers already use
|
|
// this same fallback.
|
|
updatedAt: jellyfinIsoToEpochSeconds(json['DateLastSaved'] as String? ?? json['DateModified'] as String?),
|
|
thumbPath: _absolutizeImagePath(_imageTagPath(id, json['ImageTags'])),
|
|
serverId: serverId,
|
|
serverName: serverName,
|
|
);
|
|
}
|
|
|
|
String _playlistMediaType(MediaItem item) {
|
|
if (item.kind == MediaKind.track || item.kind == MediaKind.album) return 'audio';
|
|
if (item.kind == MediaKind.photo) return 'photo';
|
|
return 'video';
|
|
}
|
|
|
|
String? _imageTagPath(String id, Object? tags) {
|
|
if (tags is! Map<String, dynamic>) return null;
|
|
final tag = tags['Primary'];
|
|
if (tag is! String) return null;
|
|
return '/Items/${_segment(id)}/Images/Primary?tag=${Uri.encodeComponent(tag)}';
|
|
}
|
|
}
|