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.
352 lines
12 KiB
Dart
352 lines
12 KiB
Dart
part of '../../jellyfin_client.dart';
|
|
|
|
mixin _JellyfinLiveTvMethods on _JellyfinClientInternals {
|
|
Future<List<Map<String, dynamic>>> _safeFetchItemsArray(
|
|
String path,
|
|
Map<String, dynamic> queryParameters, {
|
|
// ignore: unused_element_parameter
|
|
_HubRetryPolicy? retry,
|
|
// ignore: unused_element_parameter
|
|
AbortController? abort,
|
|
// ignore: unused_element_parameter
|
|
Duration? timeout,
|
|
// ignore: unused_element_parameter
|
|
bool allowEndpointFailover,
|
|
});
|
|
|
|
/// Returns `true` when this server has Live TV configured (channels
|
|
/// available). Probes `/LiveTv/Channels?limit=1`. Used by [MultiServerProvider]
|
|
/// to gate the Live TV menu.
|
|
Future<bool> hasLiveTv() async {
|
|
try {
|
|
final response = await _http.get(
|
|
'/LiveTv/Channels',
|
|
queryParameters: {'limit': '1', 'userId': connection.userId},
|
|
);
|
|
if (response.statusCode != 200) return false;
|
|
final data = response.data;
|
|
if (data is Map<String, dynamic>) {
|
|
final total = data['TotalRecordCount'];
|
|
if (total is int) return total > 0;
|
|
final items = data['Items'];
|
|
if (items is List) return items.isNotEmpty;
|
|
}
|
|
return false;
|
|
} catch (e) {
|
|
appLogger.d('${dialect.productName} Live TV probe failed', error: e);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// Fetch the user's Live TV channel list. Each `BaseItemDto` of type
|
|
/// `TvChannel` is mapped to a [LiveTvChannel].
|
|
Future<List<LiveTvChannel>> fetchLiveTvChannels() async {
|
|
final items = await _safeFetchItemsArray('/LiveTv/Channels', {
|
|
'userId': connection.userId,
|
|
'enableImages': 'true',
|
|
'enableUserData': 'true',
|
|
'sortBy': 'SortName',
|
|
'sortOrder': 'Ascending',
|
|
});
|
|
return items.map(_channelFromJson).toList();
|
|
}
|
|
|
|
/// EPG / programs grid. [channelIds] scopes to specific channels (when
|
|
/// empty, the server returns programs across all channels). [beginsAt] /
|
|
/// [endsAt] are epoch seconds and bound the time window — both MediaBrowser
|
|
/// dialects use ISO 8601 strings on the wire.
|
|
Future<List<LiveTvProgram>> fetchLiveTvPrograms({
|
|
List<String> channelIds = const [],
|
|
int? beginsAt,
|
|
int? endsAt,
|
|
}) async {
|
|
DateTime? toDt(int? epoch) => epoch == null ? null : DateTime.fromMillisecondsSinceEpoch(epoch * 1000, isUtc: true);
|
|
final params = <String, dynamic>{
|
|
'userId': connection.userId,
|
|
'enableImages': 'true',
|
|
'sortBy': 'StartDate',
|
|
'sortOrder': 'Ascending',
|
|
if (channelIds.isNotEmpty) 'channelIds': channelIds.join(','),
|
|
if (beginsAt != null) 'minStartDate': toDt(beginsAt)!.toIso8601String(),
|
|
if (endsAt != null) 'maxStartDate': toDt(endsAt)!.toIso8601String(),
|
|
};
|
|
final items = await _safeFetchItemsArray('/LiveTv/Programs', params);
|
|
return items.map(_programFromJson).toList();
|
|
}
|
|
|
|
LiveTvProgram _programFromJson(Map<String, dynamic> json) {
|
|
final id = json['Id'] as String?;
|
|
|
|
final tags = json['ImageTags'];
|
|
String? primaryTag;
|
|
if (tags is Map<String, dynamic>) {
|
|
primaryTag = tags['Primary'] as String?;
|
|
}
|
|
final thumbPath = (id != null && primaryTag != null)
|
|
? _absolutizeImagePath('/Items/${_segment(id)}/Images/Primary?tag=${Uri.encodeComponent(primaryTag)}')
|
|
: null;
|
|
return LiveTvProgram(
|
|
key: id,
|
|
ratingKey: id,
|
|
guid: null,
|
|
title: json['Name'] as String? ?? t.liveTv.unknownProgram,
|
|
summary: json['Overview'] as String?,
|
|
type: 'episode',
|
|
year: (json['ProductionYear'] as num?)?.toInt(),
|
|
beginsAt: jellyfinIsoToEpochSeconds(json['StartDate'] as String?),
|
|
endsAt: jellyfinIsoToEpochSeconds(json['EndDate'] as String?),
|
|
grandparentTitle: json['SeriesName'] as String?,
|
|
parentTitle: json['SeasonName'] as String?,
|
|
index: (json['IndexNumber'] as num?)?.toInt(),
|
|
parentIndex: (json['ParentIndexNumber'] as num?)?.toInt(),
|
|
thumb: thumbPath,
|
|
art: null,
|
|
channelIdentifier: json['ChannelId'] as String?,
|
|
channelCallSign: json['ChannelCallSign'] as String? ?? json['ChannelName'] as String?,
|
|
live: json['IsLive'] as bool?,
|
|
premiere: json['IsPremiere'] as bool?,
|
|
serverId: serverId,
|
|
serverName: serverName,
|
|
);
|
|
}
|
|
|
|
LiveTvChannel _channelFromJson(Map<String, dynamic> json) {
|
|
final id = json['Id'] as String? ?? '';
|
|
final name = json['Name'] as String?;
|
|
final number = json['Number'] as String? ?? json['ChannelNumber'] as String?;
|
|
final tags = json['ImageTags'];
|
|
String? primaryTag;
|
|
if (tags is Map<String, dynamic>) {
|
|
primaryTag = tags['Primary'] as String?;
|
|
}
|
|
final thumbPath = primaryTag != null
|
|
? _absolutizeImagePath('/Items/${_segment(id)}/Images/Primary?tag=${Uri.encodeComponent(primaryTag)}')
|
|
: null;
|
|
return LiveTvChannel(
|
|
key: id,
|
|
identifier: id,
|
|
callSign: json['CallSign'] as String?,
|
|
title: name,
|
|
thumb: thumbPath,
|
|
art: null,
|
|
number: number,
|
|
hd: false,
|
|
lineup: null,
|
|
slug: null,
|
|
drm: null,
|
|
serverId: serverId,
|
|
serverName: serverName,
|
|
);
|
|
}
|
|
|
|
@override
|
|
LiveTvSupport get liveTv => _JellyfinLiveTvSupport(this as JellyfinClient);
|
|
}
|
|
|
|
/// Adapter from [LiveTvSupport] to MediaBrowser channel/program helpers.
|
|
class _JellyfinLiveTvSupport implements LiveTvSupport {
|
|
final JellyfinClient _client;
|
|
_JellyfinLiveTvSupport(this._client);
|
|
|
|
@override
|
|
LiveTvDvrSupport? get dvr => null;
|
|
|
|
@override
|
|
Future<bool> isAvailable() => _client.hasLiveTv();
|
|
|
|
@override
|
|
Future<List<LiveTvChannel>> fetchChannels({String? lineup}) => _client.fetchLiveTvChannels();
|
|
|
|
@override
|
|
Future<List<LiveTvProgram>> fetchSchedule({DateTime? from, DateTime? to}) {
|
|
int? toEpoch(DateTime? dt) => dt == null ? null : dt.millisecondsSinceEpoch ~/ 1000;
|
|
return _client.fetchLiveTvPrograms(beginsAt: toEpoch(from), endsAt: toEpoch(to));
|
|
}
|
|
|
|
@override
|
|
Future<LiveTvStreamResolution?> resolveStreamUrl(String channelKey, {String? dvrKey}) async {
|
|
final info = await _client.getPlaybackInfo(
|
|
channelKey,
|
|
autoOpenLiveStream: true,
|
|
enableDirectPlay: false,
|
|
enableDirectStream: false,
|
|
enableTranscoding: true,
|
|
allowVideoStreamCopy: true,
|
|
allowAudioStreamCopy: true,
|
|
);
|
|
final sources = info['MediaSources'] as List;
|
|
if (sources.isEmpty) return null;
|
|
final firstSource = sources.first;
|
|
if (firstSource is! Map<String, dynamic>) {
|
|
throw PlaybackException(
|
|
'${_client.dialect.productName} returned invalid Live TV playback data',
|
|
reason: PlaybackFailureReason.invalidPlaybackData,
|
|
);
|
|
}
|
|
final source = firstSource;
|
|
|
|
String? nonEmptyString(dynamic raw) => raw is String && raw.isNotEmpty ? raw : null;
|
|
|
|
var playSessionId = nonEmptyString(info['PlaySessionId']);
|
|
var mediaSourceId = nonEmptyString(source['Id']);
|
|
var liveStreamId = nonEmptyString(source['LiveStreamId']);
|
|
final rawUrl = nonEmptyString(source['TranscodingUrl']);
|
|
if (rawUrl == null) {
|
|
appLogger.w('${_client.dialect.productName} Live TV negotiation returned no HLS transcode URL');
|
|
return null;
|
|
}
|
|
final rawUri = Uri.tryParse(rawUrl);
|
|
if (rawUri == null || !rawUri.path.toLowerCase().endsWith('.m3u8')) {
|
|
appLogger.w('${_client.dialect.productName} Live TV negotiation returned no HLS transcode URL');
|
|
return null;
|
|
}
|
|
final url = _client._withApiKey(rawUrl);
|
|
final query = Uri.tryParse(url)?.queryParameters;
|
|
playSessionId ??= query?['PlaySessionId'];
|
|
mediaSourceId ??= query?['MediaSourceId'];
|
|
liveStreamId ??= query?['LiveStreamId'];
|
|
return LiveTvStreamResolution(
|
|
url: url,
|
|
playSessionId: playSessionId,
|
|
mediaSourceId: mediaSourceId,
|
|
liveStreamId: liveStreamId,
|
|
playMethod: 'Transcode',
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<LiveTvPlaybackSession?> startPlayback(String channelKey, {String? dvrKey}) async {
|
|
final resolution = await resolveStreamUrl(channelKey, dvrKey: dvrKey);
|
|
if (resolution == null) return null;
|
|
return _JellyfinLiveTvPlaybackSession(_client, channelKey, resolution);
|
|
}
|
|
|
|
/// SharedPreferences key for the locally-persisted favorite-channel list.
|
|
/// Keyed by the compound connection id (`{machineId}/{userId}`) so users on
|
|
/// the same MediaBrowser server don't share favorites.
|
|
// Keep the legacy prefix: the connection id isolates both dialects, and changing it would lose Jellyfin ordering.
|
|
String get _favoritesPrefsKey => 'jellyfin_fav_channels:${_client.connection.id}';
|
|
|
|
/// Legacy bare-machineId key, kept for one-shot migration.
|
|
String get _legacyFavoritesPrefsKey => 'jellyfin_fav_channels:${_client.serverId}';
|
|
|
|
@override
|
|
Future<String> buildFavoriteChannelSource({String? lineup}) async => 'server://${_client.serverId}/jellyfin';
|
|
|
|
@override
|
|
String get favoriteStoreKey => 'jellyfin:${_client.connection.id}';
|
|
|
|
@override
|
|
FavoriteChannelPersistenceMode get favoritePersistenceMode => FavoriteChannelPersistenceMode.serverSlice;
|
|
|
|
Future<List<FavoriteChannel>> _readPersistedFavoriteChannels() =>
|
|
_client._favoritesRepository.read(key: _favoritesPrefsKey, legacyKey: _legacyFavoritesPrefsKey);
|
|
|
|
/// Local list is the source of truth (preserves order + display fields).
|
|
/// Server-side `IsFavorite` is mirrored on writes via [setFavoriteChannels].
|
|
@override
|
|
Future<List<FavoriteChannel>> fetchFavoriteChannels() => _readPersistedFavoriteChannels();
|
|
|
|
@override
|
|
Future<void> setFavoriteChannels(List<FavoriteChannel> channels) async {
|
|
final previous = await _readPersistedFavoriteChannels();
|
|
final previousIds = previous.map((channel) => channel.id).toSet();
|
|
final requestedIds = channels.map((channel) => channel.id).toSet();
|
|
final confirmedIds = {...previousIds};
|
|
Object? firstError;
|
|
StackTrace? firstStackTrace;
|
|
|
|
Future<void> applyMutation(String id, bool isFavorite) async {
|
|
try {
|
|
await _client._setItemFavorite(id, isFavorite);
|
|
if (isFavorite) {
|
|
confirmedIds.add(id);
|
|
} else {
|
|
confirmedIds.remove(id);
|
|
}
|
|
} catch (error, stackTrace) {
|
|
firstError ??= error;
|
|
firstStackTrace ??= stackTrace;
|
|
appLogger.w(
|
|
'Failed to update a ${_client.dialect.productName} favorite channel',
|
|
error: error,
|
|
stackTrace: stackTrace,
|
|
);
|
|
}
|
|
}
|
|
|
|
for (final id in requestedIds.difference(previousIds)) {
|
|
await applyMutation(id, true);
|
|
}
|
|
for (final id in previousIds.difference(requestedIds)) {
|
|
await applyMutation(id, false);
|
|
}
|
|
|
|
final confirmed = <FavoriteChannel>[
|
|
for (final channel in channels)
|
|
if (confirmedIds.contains(channel.id)) channel,
|
|
for (final channel in previous)
|
|
if (!requestedIds.contains(channel.id) && confirmedIds.contains(channel.id)) channel,
|
|
];
|
|
await _client._favoritesRepository.write(_favoritesPrefsKey, confirmed);
|
|
|
|
if (firstError != null) {
|
|
Error.throwWithStackTrace(firstError!, firstStackTrace!);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A MediaBrowser live playback session: one negotiated HLS transcode URL plus
|
|
/// `/Sessions/Playing*` heartbeats via [JellyfinLiveSessionTracker]. No
|
|
/// program-scoped session and no time-shift — [recover] re-opens the same
|
|
/// negotiated URL.
|
|
class _JellyfinLiveTvPlaybackSession implements LiveTvPlaybackSession {
|
|
final JellyfinClient _client;
|
|
final String _channelKey;
|
|
final String _url;
|
|
final JellyfinLiveSessionTracker _tracker;
|
|
|
|
_JellyfinLiveTvPlaybackSession(this._client, this._channelKey, LiveTvStreamResolution resolution)
|
|
: _url = resolution.url,
|
|
_tracker = JellyfinLiveSessionTracker(
|
|
playSessionId: resolution.playSessionId,
|
|
mediaSourceId: resolution.mediaSourceId,
|
|
liveStreamId: resolution.liveStreamId,
|
|
playMethod: resolution.playMethod,
|
|
);
|
|
|
|
@override
|
|
LiveProgramInfo get program => LiveProgramInfo.none;
|
|
|
|
@override
|
|
LiveTvBackgroundPolicy get backgroundPolicy => LiveTvBackgroundPolicy.stopAndExit;
|
|
|
|
@override
|
|
CaptureBuffer? get captureBuffer => null;
|
|
|
|
@override
|
|
bool get canTimeShift => false;
|
|
|
|
@override
|
|
Future<String?> streamUrlAt({int? offsetSeconds}) async => offsetSeconds == null ? _url : null;
|
|
|
|
@override
|
|
Future<CaptureBuffer?> reportTimeline({
|
|
required String state,
|
|
required int positionMs,
|
|
required int durationMs,
|
|
}) async {
|
|
await _tracker.report(
|
|
client: _client,
|
|
itemId: _channelKey,
|
|
state: state,
|
|
position: Duration(milliseconds: positionMs),
|
|
duration: Duration(milliseconds: durationMs),
|
|
);
|
|
return null;
|
|
}
|
|
|
|
@override
|
|
Future<LiveTvPlaybackSession?> recover({required bool directStream, required bool directStreamAudio}) async => this;
|
|
}
|