Files
plezy/lib/services/jellyfin_client/parts/live_tv.dart
T
edde746 5f397a99d9 fix(discover): let a refreshed row override a stale local watch patch
Pausing an episode on one device, finishing it on another and pressing
Refresh left the first device showing the old "minutes left". Restarting the
app showed the right value. Two independent defects produce that, and either
alone reproduces the report.

The first is the watch-state overlay. Every local watch event lands in
WatchStateStore as a patch, and WatchStateSnapshot.apply overwrites
viewOffsetMs unconditionally; isNewerThan only ever orders one patch against
another, never against the server row underneath. Nothing expires a patch and
nothing clears the map except a profile switch, so the Mac's own paused
position kept winning over every subsequent fetch until the process died.

A patch exists to bridge the gap between a local action and the next server
read of that item, so it should stop applying once that read happens. The
store now records the watermark at which a successful authoritative response
returned each key, and suppresses an acknowledged session patch at or below
it. Only a watermark is stored, never the observed state: WatchStateSnapshot
cannot hold a container's leaf counts, and keeping max() per key makes the
order two concurrent responses complete irrelevant. Suppression is a
read-time predicate, so nothing mutates during build.

The barrier covers the parentChain too. patchForItem picks the newest of the
item's own entry and its ancestors', so retiring only the item's entry would
let an older season mark win and render watched/0 -- worse than either the
stale value or the fresh one. An authoritative read of a child already
reflects any container mark that preceded it, so the child's observation
judges its ancestors as well; a newer container action still wins.

Provenance decides what may be suppressed at all. WatchStateEvent now carries
serverAcknowledged, defaulting to false so an unclassified emit site degrades
to today's behaviour rather than silently becoming retireable. An offline
write is owed to the server and a read must never retire it, so it stays
until a WatchPatchPromotionNotifier promotion says the queue replayed it. That
channel is deliberately not a WatchStateEvent: OfflineWatchSyncService reacts
to watched/unwatched by purging queued progress, so replaying one there would
delete a newer rewatch. Promotion matches an exact WatchPatchId -- session
minted for live crossings, derived from the persisted (profile, row, revision)
for queued ones so it still joins after a restart.

Report acceptance is not delivery: PlaybackReportSession resolves true for a
same-state startup heartbeat it drops, so acknowledgement now keys on
onDelivered. A MediaBrowser Started saves play count and last-played date but
not the position, so it cannot acknowledge an offset. No report-derived
watched crossing is acknowledged on any backend -- Jellyfin hard-codes its
threshold and Plex never loads the server pref that would tell it the real
one -- so only an awaited explicit markWatched settles one.

The second defect is that a failed Refresh reported success. Plex _fetchHubs
and the Jellyfin hub legs both degrade a failure to an empty list, and the
library prefetch discarded its failures, so a server whose every hub request
failed was recorded as succeeded; DiscoverProvider then kept the previous rows,
set loaded and surfaced nothing. Worse, the background Continue Watching
refresh wiped the row outright on zero success.

Hub legs now report what they degraded through a HubFetchDiagnostics sink,
which keeps partial rows alongside the failure and leaves every existing
caller untouched. Failures ride through the aggregation results, a leg that
could not run because discovery failed contributes that failure rather than a
successful no-op, and loaded-server ids became succeeded - failed - cancelled
so one bad leg no longer caches a server as covered and blocks its retry. The
toolbar awaits a DiscoverRefreshOutcome and shows the existing unableToLoad
snackbar on failure while the retained rows stay on screen. Rollback after a
mid-pass exception is version-guarded, refilters against the current hidden
libraries and no longer publishes a system shelf the pass never committed.

Observations are staged with the pass and flushed only once the same disposal,
generation and exception checks that authorise committing those rows have
passed, so a discarded or rolled-back response can never suppress a patch.

Also fixes a live data-loss race the promotion work would have built on:
upsertProgressAction stamped a millisecond timestamp and updated the row in
place, so a rewatch queued during an in-flight replay was deleted by id.
Revisions are now strictly monotonic per row, replay deletes and retry updates
compare against them, and the upsert resets the retry fields because a new
revision is a new logical action.

close #1829
2026-08-08 09:09:48 +02:00

354 lines
13 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,
// ignore: unused_element_parameter
HubFetchDiagnostics? diagnostics,
});
/// 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;
}