part of '../../jellyfin_client.dart'; mixin _JellyfinLiveTvMethods on _JellyfinClientInternals { Future>> _safeFetchItemsArray( String path, Map queryParameters, { // ignore: unused_element_parameter _HubRetryPolicy? retry, }); /// 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 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) { 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('Jellyfin 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> 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 — Jellyfin uses /// ISO 8601 strings on the wire. Future> fetchLiveTvPrograms({ List channelIds = const [], int? beginsAt, int? endsAt, }) async { DateTime? toDt(int? epoch) => epoch == null ? null : DateTime.fromMillisecondsSinceEpoch(epoch * 1000, isUtc: true); final params = { '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 json) { final id = json['Id'] as String?; final tags = json['ImageTags']; String? primaryTag; if (tags is Map) { 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 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) { 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 Jellyfin channel/program helpers. class _JellyfinLiveTvSupport implements LiveTvSupport { final JellyfinClient _client; _JellyfinLiveTvSupport(this._client); @override LiveTvDvrSupport? get dvr => null; @override Future isAvailable() => _client.hasLiveTv(); @override Future> fetchChannels({String? lineup}) => _client.fetchLiveTvChannels(); @override Future> 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 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) { throw const PlaybackException( 'Jellyfin 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('Jellyfin 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('Jellyfin 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 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 two /// Jellyfin users on the same server don't share favorites. 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 buildFavoriteChannelSource({String? lineup}) async => 'server://${_client.serverId}/jellyfin'; @override String get favoriteStoreKey => 'jellyfin:${_client.connection.id}'; @override FavoriteChannelPersistenceMode get favoritePersistenceMode => FavoriteChannelPersistenceMode.serverSlice; Future> _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> fetchFavoriteChannels() => _readPersistedFavoriteChannels(); @override Future setFavoriteChannels(List 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 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 Jellyfin 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 = [ 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 Jellyfin 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 streamUrlAt({int? offsetSeconds}) async => offsetSeconds == null ? _url : null; @override Future 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 recover({required bool directStream, required bool directStreamAudio}) async => this; }