refactor(live-tv): split optional DVR support

This commit is contained in:
edde746
2026-07-12 08:42:25 +02:00
parent a5449a1430
commit 359a6bf02a
15 changed files with 305 additions and 479 deletions
+18 -8
View File
@@ -115,29 +115,31 @@ class LiveTvStreamResolution {
}
/// Backend-neutral live-TV operations. Implementations are obtained via
/// [MediaServerClient.liveTv]; the getter returns `null` when the server has no
/// live-TV support configured.
/// [MediaServerClient.liveTv]. Runtime availability is reported by
/// [isAvailable]; recording and DVR administration are exposed separately by
/// the optional [dvr] adapter.
///
/// Plex servers expose multiple per-DVR lineups (`/livetv/dvrs`), Jellyfin
/// servers expose a single flat channel list. The interface flattens both:
/// callers that need DVR identity for Plex's per-lineup channel fetch use
/// [fetchDvrs]; callers that only need the channel list pass the optional
/// [lineup] (Plex provider identifier) to [fetchChannels].
/// [LiveTvDvrSupport.fetchDvrs]; callers that only need the channel list pass
/// the optional [lineup] (Plex provider identifier) to [fetchChannels].
///
/// Stream URL resolution differs sharply by backend: Plex's DVR allocates a
/// transcode session and returns a session-scoped path; Jellyfin negotiates
/// a direct-play URL. [startPlayback] owns that difference behind
/// [LiveTvPlaybackSession] — it is the only entry playback callers use.
abstract class LiveTvSupport {
/// Recording and DVR administration, when implemented by this backend.
/// Jellyfin's channel, guide, and playback support remains available while
/// this is `null` until its recording API is wired.
LiveTvDvrSupport? get dvr;
/// Fast probe — `true` when this server has live-TV configured. Plex calls
/// `/livetv/dvrs` and returns true when any DVR exists; Jellyfin probes
/// `/LiveTv/Channels?limit=1`.
Future<bool> isAvailable();
/// Plex returns one entry per configured DVR; Jellyfin returns an empty
/// list (it has no per-DVR partitioning).
Future<List<LiveTvDvr>> fetchDvrs();
/// Channel list. Plex callers may pass [lineup] (the EPG provider
/// identifier from a DVR's lineup) to scope to a specific provider's
/// channels. Jellyfin ignores [lineup] and returns the flat list.
@@ -183,7 +185,15 @@ abstract class LiveTvSupport {
/// `/UserFavoriteItems/{channelId}?userId=...` flag and saves the order
/// locally.
Future<void> setFavoriteChannels(List<FavoriteChannel> channels);
}
/// Optional Plex-style recording and DVR administration capability.
///
/// Kept separate from [LiveTvSupport] so backends that support channels,
/// guide data, and playback do not need placeholder methods for unsupported
/// recording APIs.
abstract class LiveTvDvrSupport {
Future<List<LiveTvDvr>> fetchDvrs();
Future<LiveTvServerStatus> fetchLiveTvServerStatus();
Future<LiveTvDvr?> fetchDvr(String dvrId);
Future<LiveTvActivityResult<LiveTvDvr?>> createDvr({
+9 -1
View File
@@ -619,7 +619,8 @@ abstract class MediaServerClient {
/// Backend-neutral live-TV operations. Always returns a wrapper; consult
/// [LiveTvSupport.isAvailable] to find out whether the server actually
/// has live TV configured before calling other methods.
/// has live TV configured before calling other methods. Recording and DVR
/// administration are available through [MediaServerClientLiveTv.liveTvDvr].
LiveTvSupport get liveTv;
/// Resolve the download URL for [item]'s primary video file along with
@@ -681,6 +682,13 @@ extension MediaServerClientScope on MediaServerClient {
}
}
extension MediaServerClientLiveTv on MediaServerClient {
/// Optional recording/admin adapter, gated by the backend capability flag.
/// Call sites use this rather than assuming every Live TV backend supports
/// Plex's DVR surface.
LiveTvDvrSupport? get liveTvDvr => capabilities.liveTvDvr ? liveTv.dvr : null;
}
/// Optional capability for clients that can fetch a season's episodes without
/// listing generic children. Jellyfin uses this to avoid mixing local extras or
/// missing/virtual placeholders into normal season episode rails.
+2 -1
View File
@@ -294,7 +294,8 @@ class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMi
try {
final liveTv = genericClient.liveTv;
final dvrs = await liveTv.fetchDvrs();
final dvr = genericClient.liveTvDvr;
final dvrs = dvr == null ? const <LiveTvDvr>[] : await dvr.fetchDvrs();
if (dvrs.isNotEmpty) {
// Plex: one entry per DVR with its own lineup.
for (final dvr in dvrs) {
+9 -5
View File
@@ -167,7 +167,7 @@ class _LiveTvScreenState extends State<LiveTvScreen>
final futures = <Future<void>>[];
for (final serverInfo in multiServer.liveTvServers) {
final client = multiServer.getClientForServer(ServerId(serverInfo.serverId));
if (client == null || !client.capabilities.liveTvDvr) continue;
if (client == null || client.liveTvDvr == null) continue;
futures.add(_reloadGuideSafe(client, serverInfo.dvrKey));
}
if (futures.isEmpty) return;
@@ -178,7 +178,9 @@ class _LiveTvScreenState extends State<LiveTvScreen>
Future<void> _reloadGuideSafe(MediaServerClient client, String dvrId) async {
try {
await client.liveTv.reloadGuide(dvrId);
final dvr = client.liveTvDvr;
if (dvr == null) return;
await dvr.reloadGuide(dvrId);
} catch (e) {
// 403 (admin only) and transient errors are non-fatal — caller still
// re-fetches client-side channels.
@@ -191,7 +193,7 @@ class _LiveTvScreenState extends State<LiveTvScreen>
final futures = <Future<void>>[];
for (final serverInfo in multiServer.liveTvServers) {
final client = multiServer.getClientForServer(ServerId(serverInfo.serverId));
if (client == null || !client.capabilities.liveTvDvr) continue;
if (client == null || client.liveTvDvr == null) continue;
futures.add(_processRulesSafe(client));
}
if (futures.isEmpty) return;
@@ -203,7 +205,9 @@ class _LiveTvScreenState extends State<LiveTvScreen>
Future<void> _processRulesSafe(MediaServerClient client) async {
try {
await client.liveTv.processRecordingRules();
final dvr = client.liveTvDvr;
if (dvr == null) return;
await dvr.processRecordingRules();
} catch (e) {
appLogger.d('processRecordingRules failed: $e');
}
@@ -215,7 +219,7 @@ class _LiveTvScreenState extends State<LiveTvScreen>
void _refreshVisibleTabs(MultiServerProvider multiServer) {
final hasDvr = multiServer.liveTvServers.any((s) {
final c = multiServer.getClientForServer(ServerId(s.serverId));
return c != null && c.capabilities.liveTvDvr;
return c?.liveTvDvr != null;
});
final newTabs = [LiveTvTab.guide, LiveTvTab.whatsOn, if (hasDvr) LiveTvTab.recordings];
if (listEquals(_visibleTabs, newTabs)) return;
@@ -6,6 +6,7 @@ import 'package:provider/provider.dart';
import '../../focus/focusable_action_bar.dart';
import '../../focus/focusable_wrapper.dart';
import '../../i18n/strings.g.dart';
import '../../media/media_server_client.dart';
import '../../models/livetv_channel.dart';
import '../../mixins/mounted_set_state_mixin.dart';
import '../../models/livetv_program.dart';
@@ -92,7 +93,7 @@ class _LiveTvShowScheduleScreenState extends State<LiveTvShowScheduleScreen>
/// lookup is needed.
bool get _canRecord {
final client = context.read<MultiServerProvider>().getClientForServer(ServerId(widget.serverId));
return client != null && client.capabilities.liveTvDvr;
return client?.liveTvDvr != null;
}
Future<void> _onRecordShow() async {
@@ -27,8 +27,9 @@ enum RecordOutcome { scheduled, updated, alreadyScheduled, adminRequired, target
/// - 409 (duplicate): surface "Already scheduled" via info snackbar.
/// - Other: generic failure snackbar.
Future<RecordOutcome?> recordProgram(BuildContext context, MediaServerClient client, LiveTvProgram program) async {
final dvr = client.liveTvDvr;
final guid = program.guid;
if (guid == null || guid.isEmpty) {
if (dvr == null || guid == null || guid.isEmpty) {
if (!context.mounted) return null;
showSnackBar(context, t.liveTv.recordNotAvailable, type: SnackBarType.error);
return RecordOutcome.failed;
@@ -36,7 +37,7 @@ Future<RecordOutcome?> recordProgram(BuildContext context, MediaServerClient cli
List<SubscriptionTemplate> templates;
try {
templates = await client.liveTv.getSubscriptionTemplate(guid);
templates = await dvr.getSubscriptionTemplate(guid);
} catch (e) {
appLogger.e('Failed to fetch recording template', error: e);
if (!context.mounted) return null;
@@ -101,8 +102,9 @@ Future<RecordOutcome?> editRecordingRule(BuildContext context, MediaServerClient
}
Future<bool> confirmCancelGrab(BuildContext context, MediaServerClient client, MediaGrabOperation op) async {
final dvr = client.liveTvDvr;
final operationKey = op.operationKey;
if (operationKey.isEmpty) {
if (dvr == null || operationKey.isEmpty) {
showSnackBar(context, t.liveTv.recordingFailed, type: SnackBarType.error);
return false;
}
@@ -116,7 +118,7 @@ Future<bool> confirmCancelGrab(BuildContext context, MediaServerClient client, M
);
if (!confirmed) return false;
try {
await client.liveTv.cancelGrab(operationKey);
await dvr.cancelGrab(operationKey);
if (context.mounted) {
showSnackBar(context, t.liveTv.recordingCancelled, type: SnackBarType.success);
}
@@ -131,6 +133,11 @@ Future<bool> confirmCancelGrab(BuildContext context, MediaServerClient client, M
}
Future<bool> confirmDeleteRule(BuildContext context, MediaServerClient client, MediaSubscription rule) async {
final dvr = client.liveTvDvr;
if (dvr == null) {
showSnackBar(context, t.liveTv.recordingFailed, type: SnackBarType.error);
return false;
}
final confirmed = await showConfirmDialog(
context,
title: t.liveTv.deleteRuleTitle,
@@ -140,7 +147,7 @@ Future<bool> confirmDeleteRule(BuildContext context, MediaServerClient client, M
);
if (!confirmed) return false;
try {
await client.liveTv.deleteRecordingRule(rule.key);
await dvr.deleteRecordingRule(rule.key);
if (context.mounted) {
showSnackBar(context, t.liveTv.recordingRuleDeleted, type: SnackBarType.success);
}
@@ -97,7 +97,7 @@ class _ProgramDetailsSheetContentState extends State<_ProgramDetailsSheetContent
bool get _canRecord {
final client = widget.client;
if (client == null) return false;
if (!client.capabilities.liveTvDvr) return false;
if (client.liveTvDvr == null) return false;
final guid = widget.program.guid;
return guid != null && guid.isNotEmpty;
}
@@ -130,7 +130,12 @@ class _ProgramDetailsSheetContentState extends State<_ProgramDetailsSheetContent
return;
}
try {
final mapped = await client.liveTv.fetchSubscriptionMapping(providerId: providerId, ratingKeys: [ratingKey]);
final dvr = client.liveTvDvr;
if (dvr == null) {
setStateIfMounted(() => _checkedMapping = true);
return;
}
final mapped = await dvr.fetchSubscriptionMapping(providerId: providerId, ratingKeys: [ratingKey]);
final match = mapped.where((s) => s.key.isNotEmpty).firstOrNull;
if (!mounted) return;
setState(() {
+7 -2
View File
@@ -190,6 +190,11 @@ class _RecordOptionsContentState extends State<_RecordOptionsContent> {
}
Future<void> _save() async {
final dvr = widget.client.liveTvDvr;
if (dvr == null) {
_close(RecordOutcome.failed);
return;
}
final targetSectionId = widget.isEdit ? null : _effectiveSectionId(_eligibleLibraries);
if (!widget.isEdit && targetSectionId == null) {
_close(RecordOutcome.targetMissing);
@@ -203,7 +208,7 @@ class _RecordOptionsContentState extends State<_RecordOptionsContent> {
_close(RecordOutcome.failed);
return;
}
await widget.client.liveTv.updateRecordingRule(id, Map.of(_dirtyPrefs));
await dvr.updateRecordingRule(id, Map.of(_dirtyPrefs));
if (!mounted) return;
_close(RecordOutcome.updated);
} else {
@@ -212,7 +217,7 @@ class _RecordOptionsContentState extends State<_RecordOptionsContent> {
prefs: Map.of(_dirtyPrefs),
targetLibrarySectionID: targetSectionId,
);
await widget.client.liveTv.createRecordingRule(request);
await dvr.createRecordingRule(request);
_persistTargetPick();
if (!mounted) return;
_close(RecordOutcome.scheduled);
+4 -3
View File
@@ -402,9 +402,10 @@ class GuideTabState extends State<GuideTab> with MountedSetStateMixin, WidgetsBi
required ServerId serverId,
required Set<String> keys,
}) async {
if (!client.capabilities.liveTvDvr) return;
final dvr = client.liveTvDvr;
if (dvr == null) return;
try {
final grabs = await client.liveTv.fetchScheduledRecordings();
final grabs = await dvr.fetchScheduledRecordings();
for (final grab in grabs) {
_addRecordingKeysForGrab(grab, serverId: ServerId(serverId), keys: keys);
}
@@ -413,7 +414,7 @@ class GuideTabState extends State<GuideTab> with MountedSetStateMixin, WidgetsBi
}
try {
final rules = await client.liveTv.fetchRecordingRules(includeGrabs: true, includeStorage: false);
final rules = await dvr.fetchRecordingRules(includeGrabs: true, includeStorage: false);
for (final rule in rules) {
for (final grab in rule.grabOperations) {
_addRecordingKeysForGrab(grab, serverId: ServerId(serverId), keys: keys);
+4 -3
View File
@@ -124,10 +124,11 @@ class RecordingsTabState extends State<RecordingsTab> {
if (!seenServers.add(serverInfo.serverId)) continue;
final client = multiServer.getClientForServer(ServerId(serverInfo.serverId));
if (client == null) continue;
if (!client.capabilities.liveTvDvr) continue;
final dvr = client.liveTvDvr;
if (dvr == null) continue;
try {
final grabs = await client.liveTv.fetchScheduledRecordings();
final rules = await client.liveTv.fetchRecordingRules();
final grabs = await dvr.fetchScheduledRecordings();
final rules = await dvr.fetchRecordingRules();
results.add(_ServerRecordings(serverId: serverInfo.serverId, client: client, grabs: grabs, rules: rules));
} catch (e) {
appLogger.e('Failed to load recordings for ${serverInfo.serverId}', error: e);
-8
View File
@@ -31,15 +31,7 @@ import '../models/audio_quality_preset.dart';
import '../models/jellyfin/jellyfin_user_profile.dart';
import '../models/livetv_capture_buffer.dart';
import '../models/livetv_channel.dart';
import '../models/livetv_dvr.dart';
import '../models/livetv_lineup.dart';
import '../models/livetv_program.dart';
import '../models/livetv_server_status.dart';
import '../models/livetv_session.dart';
import '../models/media_grab_operation.dart';
import '../models/media_grabber_device.dart';
import '../models/media_provider_info.dart';
import '../models/media_subscription.dart';
import '../media/media_source_info.dart';
import '../media/media_sort.dart';
import '../media/media_version.dart';
+2 -186
View File
@@ -145,16 +145,12 @@ class _JellyfinLiveTvSupport implements LiveTvSupport {
final JellyfinClient _client;
_JellyfinLiveTvSupport(this._client);
Future<T> _unsupported<T>() async => throw UnimplementedError('Jellyfin DVR recording API is not implemented');
Never _unsupportedSync() => throw UnimplementedError('Jellyfin DVR recording API is not implemented');
@override
LiveTvDvrSupport? get dvr => null;
@override
Future<bool> isAvailable() => _client.hasLiveTv();
@override
Future<List<LiveTvDvr>> fetchDvrs() async => const [];
@override
Future<List<LiveTvChannel>> fetchChannels({String? lineup}) => _client.fetchLiveTvChannels();
@@ -271,186 +267,6 @@ class _JellyfinLiveTvSupport implements LiveTvSupport {
appLogger.e('Failed to save Jellyfin favorite channels', error: e);
}
}
@override
Future<LiveTvServerStatus> fetchLiveTvServerStatus() => _unsupported();
@override
Future<LiveTvDvr?> fetchDvr(String dvrId) => _unsupported();
@override
Future<LiveTvActivityResult<LiveTvDvr?>> createDvr({
required List<String> devices,
required List<String> lineups,
String? language,
String? country,
String? postalCode,
}) => _unsupported();
@override
Future<void> deleteDvr(String dvrId) => _unsupported();
@override
Future<void> updateDvrPrefs(String dvrId, Map<String, Object?> prefs) => _unsupported();
@override
Future<void> attachDeviceToDvr(String dvrId, String deviceId) => _unsupported();
@override
Future<void> detachDeviceFromDvr(String dvrId, String deviceId) => _unsupported();
@override
Future<void> addLineupToDvr(String dvrId, String lineupUri) => _unsupported();
@override
Future<void> removeLineupFromDvr(String dvrId, String lineupUri) => _unsupported();
@override
Future<LiveTvActivityResult<void>> reloadGuide(String dvrId) => _unsupported();
@override
Future<void> cancelGuideReload(String dvrId) => _unsupported();
@override
Future<List<MediaGrabber>> fetchGrabbers({String? protocol}) => _unsupported();
@override
Future<List<MediaGrabberDevice>> fetchGrabberDevices() => _unsupported();
@override
Future<LiveTvActivityResult<List<MediaGrabberDevice>>> discoverGrabberDevices() => _unsupported();
@override
Future<MediaGrabberDevice?> fetchGrabberDevice(String deviceId) => _unsupported();
@override
Future<MediaGrabberDevice?> addGrabberDevice(String uri, {String? grabberId}) => _unsupported();
@override
Future<void> updateGrabberDevice(String deviceId, {bool? enabled, String? title}) => _unsupported();
@override
Future<void> deleteGrabberDevice(String deviceId) => _unsupported();
@override
Future<List<MediaGrabberDeviceChannel>> fetchGrabberDeviceChannels(String deviceId) => _unsupported();
@override
Future<LiveTvActivityResult<MediaGrabberDevice?>> scanGrabberDevice(
String deviceId, {
String? source,
Map<String, Object?> prefs = const {},
String? network,
String? country,
}) => _unsupported();
@override
Future<MediaGrabberDevice?> cancelGrabberDeviceScan(String deviceId) => _unsupported();
@override
Future<MediaGrabberDevice?> saveGrabberDeviceChannelMap(String deviceId, MediaGrabberChannelMapRequest request) =>
_unsupported();
@override
Future<void> updateGrabberDevicePrefs(String deviceId, Map<String, Object?> prefs) => _unsupported();
@override
String buildGrabberDeviceThumbUrl(String deviceId, int version) => _unsupportedSync();
@override
Future<List<LiveTvCountry>> fetchEpgCountries() => _unsupported();
@override
Future<List<LiveTvLanguage>> fetchEpgLanguages() => _unsupported();
@override
Future<List<LiveTvRegion>> fetchEpgRegions(String country, String epgId) => _unsupported();
@override
Future<LiveTvLineupResult> fetchEpgLineups(String country, String epgId, {String? postalCode, String? region}) =>
_unsupported();
@override
Future<List<LiveTvChannel>> fetchEpgChannelsForLineup(String lineupUri) => _unsupported();
@override
Future<List<LiveTvLineup>> fetchEpgChannelsForLineups(List<String> lineupUris) => _unsupported();
@override
Future<List<ChannelMapping>> computeEpgChannelMap({required String deviceUri, required String lineupUri}) =>
_unsupported();
@override
Future<LiveTvActivityResult<Map<String, dynamic>?>> findBestLineup({
required String deviceUri,
required String lineupGroupUri,
}) => _unsupported();
@override
Future<List<SubscriptionTemplate>> getSubscriptionTemplate(String guid) => _unsupported();
@override
Future<List<MediaSubscription>> fetchRecordingRules({bool includeGrabs = true, bool includeStorage = true}) =>
_unsupported();
@override
Future<MediaSubscription?> fetchRecordingRule(
String subscriptionId, {
bool includeGrabs = true,
bool includeStorage = true,
}) => _unsupported();
@override
Future<MediaSubscription?> createRecordingRule(MediaSubscriptionCreateRequest request) => _unsupported();
@override
Future<MediaSubscription?> updateRecordingRule(String subscriptionId, Map<String, Object?> prefs) => _unsupported();
@override
Future<void> deleteRecordingRule(String subscriptionId) => _unsupported();
@override
Future<MediaSubscription?> moveRecordingRule(String subscriptionId, {String? afterSubscriptionId}) => _unsupported();
@override
Future<void> processRecordingRules() => _unsupported();
@override
Future<List<MediaGrabOperation>> fetchScheduledRecordings() => _unsupported();
@override
Future<void> cancelGrab(String operationId) => _unsupported();
@override
Future<List<MediaSubscription>> fetchSubscriptionMapping({
required String providerId,
required List<String> ratingKeys,
bool includeStorage = true,
}) => _unsupported();
@override
Future<List<MediaProviderInfo>> fetchMediaProviders() => _unsupported();
@override
Future<void> registerMediaProvider(String url) => _unsupported();
@override
Future<void> refreshMediaProviders() => _unsupported();
@override
Future<void> unregisterMediaProvider(String providerId) => _unsupported();
@override
Future<List<LiveTvSession>> fetchLiveTvSessionsDetailed() => _unsupported();
@override
Future<LiveTvSession?> fetchLiveTvSession(String sessionId) => _unsupported();
@override
Uri buildNotificationWebSocketUri({List<String>? filters}) => _unsupportedSync();
@override
Uri buildNotificationEventSourceUri({List<String>? filters}) => _unsupportedSync();
}
/// A Jellyfin live playback session: one negotiated direct-stream URL plus
+85 -245
View File
@@ -3,7 +3,7 @@ part of '../../plex_client.dart';
const _favoriteChannelsUrl = 'https://epg.provider.plex.tv/settings/favoriteChannels';
const _providerVersionHeader = {'X-Plex-Provider-Version': '5.1'};
mixin _PlexLiveTvClientMethods on MediaServerCacheMixin {
mixin _PlexLiveTvClientMethods on MediaServerCacheMixin implements LiveTvSupport, LiveTvDvrSupport {
PlexConfig get config;
MediaServerHttpClient get _http;
@@ -175,7 +175,8 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin {
}
/// Get all DVR devices configured on this server
Future<List<LiveTvDvr>> getDvrs() async {
@override
Future<List<LiveTvDvr>> fetchDvrs() async {
return _wrapListApiCall<LiveTvDvr>(() => _http.get('/livetv/dvrs'), (response) {
final container = _getMediaContainer(response);
if (container != null && container['Dvr'] != null) {
@@ -192,17 +193,19 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin {
/// Check if this server has at least one DVR configured
Future<bool> hasDvr() async {
final dvrs = await getDvrs();
final dvrs = await fetchDvrs();
return dvrs.isNotEmpty;
}
Future<LiveTvServerStatus> getLiveTvServerStatus() async {
@override
Future<LiveTvServerStatus> fetchLiveTvServerStatus() async {
final response = await _getWithFailover('/');
final container = _getMediaContainer(response);
return LiveTvServerStatus.fromJson(container ?? const <String, dynamic>{});
}
Future<LiveTvDvr?> getDvr(String dvrId) async {
@override
Future<LiveTvDvr?> fetchDvr(String dvrId) async {
final response = await _getWithFailover('/livetv/dvrs/$dvrId');
final container = _getMediaContainer(response);
final rootMappings = container?['ChannelMapping'];
@@ -212,6 +215,7 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin {
});
}
@override
Future<LiveTvActivityResult<LiveTvDvr?>> createDvr({
required List<String> devices,
required List<String> lineups,
@@ -237,32 +241,41 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin {
);
}
@override
Future<void> deleteDvr(String dvrId) => _expectOk(() => _http.delete('/livetv/dvrs/$dvrId'));
@override
Future<void> updateDvrPrefs(String dvrId, Map<String, Object?> prefs) =>
_expectOk(() => _http.put('/livetv/dvrs/$dvrId/prefs', queryParameters: prefs));
@override
Future<void> attachDeviceToDvr(String dvrId, String deviceId) =>
_expectOk(() => _http.put('/livetv/dvrs/$dvrId/devices/$deviceId'));
@override
Future<void> detachDeviceFromDvr(String dvrId, String deviceId) =>
_expectOk(() => _http.delete('/livetv/dvrs/$dvrId/devices/$deviceId'));
@override
Future<void> addLineupToDvr(String dvrId, String lineupUri) =>
_expectOk(() => _http.put('/livetv/dvrs/$dvrId/lineups', queryParameters: {'lineup': lineupUri}));
@override
Future<void> removeLineupFromDvr(String dvrId, String lineupUri) =>
_expectOk(() => _http.delete('/livetv/dvrs/$dvrId/lineups', queryParameters: {'lineup': lineupUri}));
@override
Future<LiveTvActivityResult<void>> reloadGuide(String dvrId) async {
final response = await _http.post('/livetv/dvrs/$dvrId/reloadGuide', timeout: MediaServerTimeouts.receive);
_throwIfFailed(response);
return LiveTvActivityResult(value: null, activityUuid: _activityUuid(response));
}
@override
Future<void> cancelGuideReload(String dvrId) => _expectOk(() => _http.delete('/livetv/dvrs/$dvrId/reloadGuide'));
Future<List<MediaGrabber>> getGrabbers({String? protocol}) async {
@override
Future<List<MediaGrabber>> fetchGrabbers({String? protocol}) async {
final response = await _getWithFailover(
'/media/grabbers',
queryParameters: {
@@ -272,11 +285,13 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin {
return _extractContainerList(response, const ['MediaGrabber'], MediaGrabber.fromJson);
}
Future<List<MediaGrabberDevice>> getGrabberDevices() async {
@override
Future<List<MediaGrabberDevice>> fetchGrabberDevices() async {
final response = await _getWithFailover('/media/grabbers/devices');
return _extractContainerList(response, const ['Device', 'Devices'], MediaGrabberDevice.fromJson);
}
@override
Future<LiveTvActivityResult<List<MediaGrabberDevice>>> discoverGrabberDevices() async {
final response = await _http.post('/media/grabbers/devices/discover', timeout: MediaServerTimeouts.receive);
_throwIfFailed(response);
@@ -286,11 +301,13 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin {
);
}
Future<MediaGrabberDevice?> getGrabberDevice(String deviceId) async {
@override
Future<MediaGrabberDevice?> fetchGrabberDevice(String deviceId) async {
final response = await _getWithFailover('/media/grabbers/devices/$deviceId');
return _extractFirst(response, const ['Device', 'Devices'], MediaGrabberDevice.fromJson);
}
@override
Future<MediaGrabberDevice?> addGrabberDevice(String uri, {String? grabberId}) async {
final path = grabberId == null ? '/media/grabbers/devices' : '/media/grabbers/$grabberId/devices';
final response = await _http.post(path, queryParameters: {'uri': uri});
@@ -298,6 +315,7 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin {
return _extractFirst(response, const ['Device', 'Devices'], MediaGrabberDevice.fromJson);
}
@override
Future<void> updateGrabberDevice(String deviceId, {bool? enabled, String? title}) => _expectOk(
() => _http.put(
'/media/grabbers/devices/$deviceId',
@@ -308,14 +326,17 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin {
),
);
@override
Future<void> deleteGrabberDevice(String deviceId) =>
_expectOk(() => _http.delete('/media/grabbers/devices/$deviceId'));
Future<List<MediaGrabberDeviceChannel>> getGrabberDeviceChannels(String deviceId) async {
@override
Future<List<MediaGrabberDeviceChannel>> fetchGrabberDeviceChannels(String deviceId) async {
final response = await _getWithFailover('/media/grabbers/devices/$deviceId/channels');
return _extractContainerList(response, const ['DeviceChannel'], MediaGrabberDeviceChannel.fromJson);
}
@override
Future<LiveTvActivityResult<MediaGrabberDevice?>> scanGrabberDevice(
String deviceId, {
String? source,
@@ -340,12 +361,14 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin {
);
}
@override
Future<MediaGrabberDevice?> cancelGrabberDeviceScan(String deviceId) async {
final response = await _http.delete('/media/grabbers/devices/$deviceId/scan');
_throwIfFailed(response);
return _extractFirst(response, const ['Device', 'Devices'], MediaGrabberDevice.fromJson);
}
@override
Future<MediaGrabberDevice?> saveGrabberDeviceChannelMap(
String deviceId,
MediaGrabberChannelMapRequest request,
@@ -362,28 +385,34 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin {
return _extractFirst(response, const ['Device', 'Devices'], MediaGrabberDevice.fromJson);
}
@override
Future<void> updateGrabberDevicePrefs(String deviceId, Map<String, Object?> prefs) =>
_expectOk(() => _http.put('/media/grabbers/devices/$deviceId/prefs', queryParameters: prefs));
@override
String buildGrabberDeviceThumbUrl(String deviceId, int version) =>
'${config.baseUrl}/media/grabbers/devices/$deviceId/thumb/$version'.withPlexToken(config.token);
Future<List<LiveTvCountry>> getEpgCountries() async {
@override
Future<List<LiveTvCountry>> fetchEpgCountries() async {
final response = await _getWithFailover('/livetv/epg/countries');
return _extractContainerList(response, const ['Country'], LiveTvCountry.fromJson);
}
Future<List<LiveTvLanguage>> getEpgLanguages() async {
@override
Future<List<LiveTvLanguage>> fetchEpgLanguages() async {
final response = await _getWithFailover('/livetv/epg/languages');
return _extractContainerList(response, const ['Language'], LiveTvLanguage.fromJson);
}
Future<List<LiveTvRegion>> getEpgRegions(String country, String epgId) async {
@override
Future<List<LiveTvRegion>> fetchEpgRegions(String country, String epgId) async {
final response = await _getWithFailover('/livetv/epg/countries/$country/$epgId/regions');
return _extractContainerList(response, const ['Region'], LiveTvRegion.fromJson);
}
Future<LiveTvLineupResult> getEpgLineups(String country, String epgId, {String? postalCode, String? region}) async {
@override
Future<LiveTvLineupResult> fetchEpgLineups(String country, String epgId, {String? postalCode, String? region}) async {
final path = region == null
? '/livetv/epg/countries/$country/$epgId/lineups'
: '/livetv/epg/countries/$country/$epgId/regions/$region/lineups';
@@ -400,18 +429,21 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin {
);
}
Future<List<LiveTvChannel>> getEpgChannelsForLineup(String lineupUri) async {
@override
Future<List<LiveTvChannel>> fetchEpgChannelsForLineup(String lineupUri) async {
final response = await _getWithFailover('/livetv/epg/channels', queryParameters: {'lineup': lineupUri});
return _extractContainerList(response, const [
'Channel',
], (json) => LiveTvChannel.fromJson(json).copyWith(serverId: serverId, serverName: serverName));
}
Future<List<LiveTvLineup>> getEpgChannelsForLineups(List<String> lineupUris) async {
@override
Future<List<LiveTvLineup>> fetchEpgChannelsForLineups(List<String> lineupUris) async {
final response = await _getWithFailover('/livetv/epg/lineupchannels', queryParameters: {'lineup': lineupUris});
return _extractContainerList(response, const ['Lineup'], LiveTvLineup.fromJson);
}
@override
Future<List<ChannelMapping>> computeEpgChannelMap({required String deviceUri, required String lineupUri}) async {
final response = await _getWithFailover(
'/livetv/epg/channelmap',
@@ -420,6 +452,7 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin {
return _extractContainerList(response, const ['ChannelMapping'], ChannelMapping.fromJson);
}
@override
Future<LiveTvActivityResult<Map<String, dynamic>?>> findBestLineup({
required String deviceUri,
required String lineupGroupUri,
@@ -696,12 +729,14 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin {
}
}
@override
Future<List<SubscriptionTemplate>> getSubscriptionTemplate(String guid) async {
final response = await _getWithFailover('/media/subscriptions/template', queryParameters: {'guid': guid});
return _extractContainerList(response, const ['SubscriptionTemplate'], SubscriptionTemplate.fromJson);
}
Future<List<MediaSubscription>> getRecordingRules({bool includeGrabs = true, bool includeStorage = true}) async {
@override
Future<List<MediaSubscription>> fetchRecordingRules({bool includeGrabs = true, bool includeStorage = true}) async {
final response = await _getWithFailover(
'/media/subscriptions',
queryParameters: {'includeGrabs': includeGrabs ? 1 : 0, 'includeStorage': includeStorage ? 1 : 0},
@@ -709,7 +744,8 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin {
return _extractContainerList(response, const ['MediaSubscription'], MediaSubscription.fromJson);
}
Future<MediaSubscription?> getRecordingRule(
@override
Future<MediaSubscription?> fetchRecordingRule(
String subscriptionId, {
bool includeGrabs = true,
bool includeStorage = true,
@@ -721,12 +757,14 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin {
return _extractFirst(response, const ['MediaSubscription'], MediaSubscription.fromJson);
}
@override
Future<MediaSubscription?> createRecordingRule(MediaSubscriptionCreateRequest request) async {
final response = await _http.post(_withQuery('/media/subscriptions', _subscriptionCreateQuery(request)));
_throwIfFailed(response);
return _extractFirst(response, const ['MediaSubscription'], MediaSubscription.fromJson);
}
@override
Future<MediaSubscription?> updateRecordingRule(String subscriptionId, Map<String, Object?> prefs) async {
final response = await _http.put(
'/media/subscriptions/$subscriptionId',
@@ -736,9 +774,11 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin {
return _extractFirst(response, const ['MediaSubscription'], MediaSubscription.fromJson);
}
@override
Future<void> deleteRecordingRule(String subscriptionId) =>
_expectOk(() => _http.delete('/media/subscriptions/$subscriptionId'));
@override
Future<MediaSubscription?> moveRecordingRule(String subscriptionId, {String? afterSubscriptionId}) async {
final response = await _http.put(
'/media/subscriptions/$subscriptionId/move',
@@ -750,13 +790,16 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin {
return _extractFirst(response, const ['MediaSubscription'], MediaSubscription.fromJson);
}
@override
Future<void> processRecordingRules() => _expectOk(() => _http.post('/media/subscriptions/process'));
Future<List<MediaGrabOperation>> getScheduledRecordings() async {
@override
Future<List<MediaGrabOperation>> fetchScheduledRecordings() async {
final response = await _getWithFailover('/media/subscriptions/scheduled');
return _extractContainerList(response, const ['MediaGrabOperation'], MediaGrabOperation.fromJson);
}
@override
Future<void> cancelGrab(String operationId) {
if (operationId.isEmpty) throw ArgumentError.value(operationId, 'operationId', 'must not be empty');
final path = operationId.startsWith('/media/grabbers/operations/')
@@ -767,7 +810,8 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin {
return _expectOk(() => _http.delete(path));
}
Future<List<MediaSubscription>> getSubscriptionMapping({
@override
Future<List<MediaSubscription>> fetchSubscriptionMapping({
required String providerId,
required List<String> ratingKeys,
bool includeStorage = true,
@@ -780,16 +824,20 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin {
return _extractContainerList(response, const ['MediaSubscription'], MediaSubscription.fromJson);
}
Future<List<MediaProviderInfo>> getMediaProviders() async {
@override
Future<List<MediaProviderInfo>> fetchMediaProviders() async {
final response = await _getWithFailover('/media/providers');
return _extractContainerList(response, const ['MediaProvider'], MediaProviderInfo.fromJson);
}
@override
Future<void> registerMediaProvider(String url) =>
_expectOk(() => _http.post('/media/providers', queryParameters: {'url': url}));
@override
Future<void> refreshMediaProviders() => _expectOk(() => _http.post('/media/providers/refresh'));
@override
Future<void> unregisterMediaProvider(String providerId) =>
_expectOk(() => _http.delete('/media/providers/$providerId'));
@@ -1060,7 +1108,8 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin {
return '${config.baseUrl}$streamPath'.withPlexToken(config.token);
}
Future<List<LiveTvSession>> getLiveTvSessionsDetailed() async {
@override
Future<List<LiveTvSession>> fetchLiveTvSessionsDetailed() async {
final response = await _getWithFailover('/livetv/sessions');
return _extractContainerList(response, const [
'LiveTVSession',
@@ -1070,7 +1119,8 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin {
], LiveTvSession.fromJson);
}
Future<LiveTvSession?> getLiveTvSession(String sessionId) async {
@override
Future<LiveTvSession?> fetchLiveTvSession(String sessionId) async {
final response = await _getWithFailover('/livetv/sessions/$sessionId');
return _extractFirst(response, const [
'LiveTVSession',
@@ -1080,6 +1130,7 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin {
], LiveTvSession.fromJson);
}
@override
Uri buildNotificationWebSocketUri({List<String>? filters}) {
final base = Uri.parse(config.baseUrl);
return base.replace(
@@ -1092,6 +1143,7 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin {
);
}
@override
Uri buildNotificationEventSourceUri({List<String>? filters}) {
final base = Uri.parse(config.baseUrl);
return base.replace(
@@ -1104,6 +1156,7 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin {
}
/// Build the source URI for favorite channels: `server://{machineIdentifier}/{providerIdentifier}`
@override
Future<String> buildFavoriteChannelSource({String? lineup}) async {
final providers = _epgProvidersForLineup(lineup);
final providerIdentifier = providers.isNotEmpty ? providers.first.identifier : 'tv.plex.provider.epg';
@@ -1112,7 +1165,8 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin {
}
/// Get favorite channels from the Plex cloud.
Future<List<FavoriteChannel>> getFavoriteChannels() async {
@override
Future<List<FavoriteChannel>> fetchFavoriteChannels() async {
try {
final response = await _http.get(_favoriteChannelsUrl, headers: _providerVersionHeader);
final container = _getMediaContainer(response);
@@ -1129,6 +1183,7 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin {
}
/// Update favorite channels on the Plex cloud.
@override
Future<void> setFavoriteChannels(List<FavoriteChannel> channels) async {
try {
await _expectOk(
@@ -1144,31 +1199,21 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin {
}
@override
LiveTvSupport get liveTv => _PlexLiveTvSupport(this as PlexClient);
}
/// Plex implementation of [LiveTvSupport] — wraps the existing per-DVR
/// methods. The tune / stream-path protocol flow lives privately on
/// [PlexClient]; [startPlayback] packages it behind the backend-neutral
/// [LiveTvPlaybackSession], and [resolveStreamUrl] returns `null` because a
/// Plex stream URL is only valid inside a tuned session.
class _PlexLiveTvSupport implements LiveTvSupport {
final PlexClient _client;
_PlexLiveTvSupport(this._client);
LiveTvSupport get liveTv => this as LiveTvSupport;
@override
Future<bool> isAvailable() => _client.hasDvr();
LiveTvDvrSupport get dvr => this as LiveTvDvrSupport;
@override
Future<List<LiveTvDvr>> fetchDvrs() => _client.getDvrs();
Future<bool> isAvailable() => hasDvr();
@override
Future<List<LiveTvChannel>> fetchChannels({String? lineup}) => _client.getEpgChannels(lineup: lineup);
Future<List<LiveTvChannel>> fetchChannels({String? lineup}) => getEpgChannels(lineup: lineup);
@override
Future<List<LiveTvProgram>> fetchSchedule({DateTime? from, DateTime? to}) {
int? toEpoch(DateTime? dt) => dt == null ? null : dt.millisecondsSinceEpoch ~/ 1000;
return _client.getEpgGrid(beginsAt: toEpoch(from), endsAt: toEpoch(to));
return getEpgGrid(beginsAt: toEpoch(from), endsAt: toEpoch(to));
}
@override
@@ -1180,219 +1225,14 @@ class _PlexLiveTvSupport implements LiveTvSupport {
appLogger.w('Plex live playback requires a dvrKey to tune $channelKey');
return Future.value(null);
}
return _PlexLiveTvPlaybackSession.start(_client, dvrKey: dvrKey, channelKey: channelKey);
return _PlexLiveTvPlaybackSession.start(this as PlexClient, dvrKey: dvrKey, channelKey: channelKey);
}
@override
Future<String> buildFavoriteChannelSource({String? lineup}) => _client.buildFavoriteChannelSource(lineup: lineup);
@override
String get favoriteStoreKey => 'plex:${_client.config.clientIdentifier}';
String get favoriteStoreKey => 'plex:${config.clientIdentifier}';
@override
FavoriteChannelPersistenceMode get favoritePersistenceMode => FavoriteChannelPersistenceMode.sharedFullList;
@override
Future<List<FavoriteChannel>> fetchFavoriteChannels() => _client.getFavoriteChannels();
@override
Future<void> setFavoriteChannels(List<FavoriteChannel> channels) => _client.setFavoriteChannels(channels);
@override
Future<LiveTvServerStatus> fetchLiveTvServerStatus() => _client.getLiveTvServerStatus();
@override
Future<LiveTvDvr?> fetchDvr(String dvrId) => _client.getDvr(dvrId);
@override
Future<LiveTvActivityResult<LiveTvDvr?>> createDvr({
required List<String> devices,
required List<String> lineups,
String? language,
String? country,
String? postalCode,
}) => _client.createDvr(
devices: devices,
lineups: lineups,
language: language,
country: country,
postalCode: postalCode,
);
@override
Future<void> deleteDvr(String dvrId) => _client.deleteDvr(dvrId);
@override
Future<void> updateDvrPrefs(String dvrId, Map<String, Object?> prefs) => _client.updateDvrPrefs(dvrId, prefs);
@override
Future<void> attachDeviceToDvr(String dvrId, String deviceId) => _client.attachDeviceToDvr(dvrId, deviceId);
@override
Future<void> detachDeviceFromDvr(String dvrId, String deviceId) => _client.detachDeviceFromDvr(dvrId, deviceId);
@override
Future<void> addLineupToDvr(String dvrId, String lineupUri) => _client.addLineupToDvr(dvrId, lineupUri);
@override
Future<void> removeLineupFromDvr(String dvrId, String lineupUri) => _client.removeLineupFromDvr(dvrId, lineupUri);
@override
Future<LiveTvActivityResult<void>> reloadGuide(String dvrId) => _client.reloadGuide(dvrId);
@override
Future<void> cancelGuideReload(String dvrId) => _client.cancelGuideReload(dvrId);
@override
Future<List<MediaGrabber>> fetchGrabbers({String? protocol}) => _client.getGrabbers(protocol: protocol);
@override
Future<List<MediaGrabberDevice>> fetchGrabberDevices() => _client.getGrabberDevices();
@override
Future<LiveTvActivityResult<List<MediaGrabberDevice>>> discoverGrabberDevices() => _client.discoverGrabberDevices();
@override
Future<MediaGrabberDevice?> fetchGrabberDevice(String deviceId) => _client.getGrabberDevice(deviceId);
@override
Future<MediaGrabberDevice?> addGrabberDevice(String uri, {String? grabberId}) =>
_client.addGrabberDevice(uri, grabberId: grabberId);
@override
Future<void> updateGrabberDevice(String deviceId, {bool? enabled, String? title}) =>
_client.updateGrabberDevice(deviceId, enabled: enabled, title: title);
@override
Future<void> deleteGrabberDevice(String deviceId) => _client.deleteGrabberDevice(deviceId);
@override
Future<List<MediaGrabberDeviceChannel>> fetchGrabberDeviceChannels(String deviceId) =>
_client.getGrabberDeviceChannels(deviceId);
@override
Future<LiveTvActivityResult<MediaGrabberDevice?>> scanGrabberDevice(
String deviceId, {
String? source,
Map<String, Object?> prefs = const {},
String? network,
String? country,
}) => _client.scanGrabberDevice(deviceId, source: source, prefs: prefs, network: network, country: country);
@override
Future<MediaGrabberDevice?> cancelGrabberDeviceScan(String deviceId) => _client.cancelGrabberDeviceScan(deviceId);
@override
Future<MediaGrabberDevice?> saveGrabberDeviceChannelMap(String deviceId, MediaGrabberChannelMapRequest request) =>
_client.saveGrabberDeviceChannelMap(deviceId, request);
@override
Future<void> updateGrabberDevicePrefs(String deviceId, Map<String, Object?> prefs) =>
_client.updateGrabberDevicePrefs(deviceId, prefs);
@override
String buildGrabberDeviceThumbUrl(String deviceId, int version) =>
_client.buildGrabberDeviceThumbUrl(deviceId, version);
@override
Future<List<LiveTvCountry>> fetchEpgCountries() => _client.getEpgCountries();
@override
Future<List<LiveTvLanguage>> fetchEpgLanguages() => _client.getEpgLanguages();
@override
Future<List<LiveTvRegion>> fetchEpgRegions(String country, String epgId) => _client.getEpgRegions(country, epgId);
@override
Future<LiveTvLineupResult> fetchEpgLineups(String country, String epgId, {String? postalCode, String? region}) =>
_client.getEpgLineups(country, epgId, postalCode: postalCode, region: region);
@override
Future<List<LiveTvChannel>> fetchEpgChannelsForLineup(String lineupUri) => _client.getEpgChannelsForLineup(lineupUri);
@override
Future<List<LiveTvLineup>> fetchEpgChannelsForLineups(List<String> lineupUris) =>
_client.getEpgChannelsForLineups(lineupUris);
@override
Future<List<ChannelMapping>> computeEpgChannelMap({required String deviceUri, required String lineupUri}) =>
_client.computeEpgChannelMap(deviceUri: deviceUri, lineupUri: lineupUri);
@override
Future<LiveTvActivityResult<Map<String, dynamic>?>> findBestLineup({
required String deviceUri,
required String lineupGroupUri,
}) => _client.findBestLineup(deviceUri: deviceUri, lineupGroupUri: lineupGroupUri);
@override
Future<List<SubscriptionTemplate>> getSubscriptionTemplate(String guid) => _client.getSubscriptionTemplate(guid);
@override
Future<List<MediaSubscription>> fetchRecordingRules({bool includeGrabs = true, bool includeStorage = true}) =>
_client.getRecordingRules(includeGrabs: includeGrabs, includeStorage: includeStorage);
@override
Future<MediaSubscription?> fetchRecordingRule(
String subscriptionId, {
bool includeGrabs = true,
bool includeStorage = true,
}) => _client.getRecordingRule(subscriptionId, includeGrabs: includeGrabs, includeStorage: includeStorage);
@override
Future<MediaSubscription?> createRecordingRule(MediaSubscriptionCreateRequest request) =>
_client.createRecordingRule(request);
@override
Future<MediaSubscription?> updateRecordingRule(String subscriptionId, Map<String, Object?> prefs) =>
_client.updateRecordingRule(subscriptionId, prefs);
@override
Future<void> deleteRecordingRule(String subscriptionId) => _client.deleteRecordingRule(subscriptionId);
@override
Future<MediaSubscription?> moveRecordingRule(String subscriptionId, {String? afterSubscriptionId}) =>
_client.moveRecordingRule(subscriptionId, afterSubscriptionId: afterSubscriptionId);
@override
Future<void> processRecordingRules() => _client.processRecordingRules();
@override
Future<List<MediaGrabOperation>> fetchScheduledRecordings() => _client.getScheduledRecordings();
@override
Future<void> cancelGrab(String operationId) => _client.cancelGrab(operationId);
@override
Future<List<MediaSubscription>> fetchSubscriptionMapping({
required String providerId,
required List<String> ratingKeys,
bool includeStorage = true,
}) => _client.getSubscriptionMapping(providerId: providerId, ratingKeys: ratingKeys, includeStorage: includeStorage);
@override
Future<List<MediaProviderInfo>> fetchMediaProviders() => _client.getMediaProviders();
@override
Future<void> registerMediaProvider(String url) => _client.registerMediaProvider(url);
@override
Future<void> refreshMediaProviders() => _client.refreshMediaProviders();
@override
Future<void> unregisterMediaProvider(String providerId) => _client.unregisterMediaProvider(providerId);
@override
Future<List<LiveTvSession>> fetchLiveTvSessionsDetailed() => _client.getLiveTvSessionsDetailed();
@override
Future<LiveTvSession?> fetchLiveTvSession(String sessionId) => _client.getLiveTvSession(sessionId);
@override
Uri buildNotificationWebSocketUri({List<String>? filters}) => _client.buildNotificationWebSocketUri(filters: filters);
@override
Uri buildNotificationEventSourceUri({List<String>? filters}) =>
_client.buildNotificationEventSourceUri(filters: filters);
}
/// A tuned Plex DVR transcode session. Holds the tune outputs
@@ -0,0 +1,134 @@
import 'dart:convert';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:plezy/connection/connection.dart';
import 'package:plezy/database/app_database.dart';
import 'package:plezy/media/ids.dart';
import 'package:plezy/media/media_server_client.dart';
import 'package:plezy/models/plex/plex_config.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/jellyfin_client.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/plex_api_cache.dart';
import 'package:plezy/services/plex_client.dart';
void main() {
late AppDatabase db;
setUp(() {
db = AppDatabase.forTesting(NativeDatabase.memory());
PlexApiCache.initialize(db);
});
tearDown(() => db.close());
PlexClient plexClient(http.Client httpClient) => PlexClient.forTesting(
config: PlexConfig(
baseUrl: 'https://plex.example.com',
token: 'plex-token',
clientIdentifier: 'plex-device',
product: 'Plezy',
version: '1',
machineIdentifier: 'plex-machine',
),
serverId: ServerId('plex-machine'),
httpClient: httpClient,
);
JellyfinClient jellyfinClient(http.Client httpClient) => JellyfinClient.forTesting(
connection: JellyfinConnection(
id: 'jellyfin-machine/user-1',
baseUrl: 'https://jellyfin.example.com',
serverName: 'Jellyfin',
serverMachineId: 'jellyfin-machine',
userId: 'user-1',
userName: 'User',
accessToken: 'jellyfin-token',
deviceId: 'device-1',
createdAt: DateTime.fromMillisecondsSinceEpoch(0),
),
httpClient: httpClient,
);
http.Response jsonResponse(Map<String, dynamic> body) =>
http.Response(jsonEncode(body), 200, headers: {'content-type': 'application/json'});
test('Plex exposes one centralized Live TV DVR adapter', () async {
final requests = <Uri>[];
final client = plexClient(
MockClient((request) async {
requests.add(request.url);
return jsonResponse({
'MediaContainer': {
'Dvr': [
{'key': 'dvr-1', 'uuid': 'dvr-1'},
],
},
});
}),
);
addTearDown(client.close);
expect(client.capabilities.liveTvDvr, isTrue);
expect(client.liveTv.dvr, same(client));
expect(client.liveTvDvr, same(client));
expect((await client.liveTvDvr!.fetchDvrs()).single.key, 'dvr-1');
expect(requests.single.path, '/livetv/dvrs');
});
test('Jellyfin keeps common Live TV support without a DVR adapter', () {
final client = jellyfinClient(MockClient((_) async => fail('DVR adapter must not issue a request')));
addTearDown(client.close);
expect(client.capabilities.liveTv, isTrue);
expect(client.capabilities.liveTvDvr, isFalse);
expect(client.liveTv.dvr, isNull);
expect(client.liveTvDvr, isNull);
});
test('availability call site gates DVR requests and still includes Jellyfin', () async {
final plexRequests = <Uri>[];
final plex = plexClient(
MockClient((request) async {
plexRequests.add(request.url);
if (request.url.path != '/livetv/dvrs') fail('Unexpected Plex request: ${request.url}');
return jsonResponse({
'MediaContainer': {
'Dvr': [
{'key': 'dvr-1', 'uuid': 'dvr-1'},
],
},
});
}),
);
final jellyfinRequests = <Uri>[];
final jellyfin = jellyfinClient(
MockClient((request) async {
jellyfinRequests.add(request.url);
if (request.url.path != '/LiveTv/Channels') fail('Jellyfin DVR endpoint was not gated: ${request.url}');
return jsonResponse({'TotalRecordCount': 1, 'Items': const []});
}),
);
final manager = MultiServerManager();
manager.debugRegisterClientForTesting(plex);
manager.debugRegisterJellyfinClientForTesting(jellyfin);
final provider = MultiServerProvider(manager, DataAggregationService(manager));
addTearDown(() {
provider.dispose();
manager.dispose();
});
await provider.checkLiveTvAvailability();
expect(plexRequests.map((uri) => uri.path), ['/livetv/dvrs']);
expect(jellyfinRequests.map((uri) => uri.path), ['/LiveTv/Channels']);
expect(
provider.liveTvServers.map((server) => (server.serverId, server.dvrKey)),
containsAll([('plex-machine', 'dvr-1'), ('jellyfin-machine', 'jellyfin')]),
);
});
}
+10 -9
View File
@@ -6,6 +6,7 @@ import 'package:drift/native.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:plezy/database/app_database.dart';
import 'package:plezy/media/media_server_client.dart';
import 'package:plezy/models/media_subscription.dart';
import 'package:plezy/models/plex/plex_config.dart';
import 'package:plezy/services/plex_api_cache.dart';
@@ -96,7 +97,7 @@ void main() {
});
addTearDown(client.close);
final dvrs = await client.liveTv.fetchDvrs();
final dvrs = await client.liveTvDvr!.fetchDvrs();
expect(dvrs, hasLength(1));
expect(dvrs.single.tuners, 2);
@@ -122,7 +123,7 @@ void main() {
});
addTearDown(client.close);
final result = await client.liveTv.createDvr(
final result = await client.liveTvDvr!.createDvr(
devices: const ['dev-a', 'dev-b'],
lineups: const ['lineup-a', 'lineup-b'],
language: 'eng',
@@ -163,7 +164,7 @@ void main() {
});
addTearDown(client.close);
final templates = await client.liveTv.getSubscriptionTemplate('plex://episode/1');
final templates = await client.liveTvDvr!.getSubscriptionTemplate('plex://episode/1');
final subscription = templates.single.subscriptions.single;
expect(subscription.selected, isTrue);
@@ -200,7 +201,7 @@ void main() {
prefs: const {'startOffsetMinutes': 5},
);
final created = await client.liveTv.createRecordingRule(request);
final created = await client.liveTvDvr!.createRecordingRule(request);
expect(captured.method, 'POST');
expect(captured.url.path, '/media/subscriptions');
@@ -235,7 +236,7 @@ void main() {
expect(overridden.targetLibrarySectionID, 5);
expect(overridden.targetSectionLocationID, isNull);
await client.liveTv.createRecordingRule(overridden);
await client.liveTvDvr!.createRecordingRule(overridden);
expect(captured.url.queryParameters['targetLibrarySectionID'], '5');
expect(captured.url.queryParameters.containsKey('targetSectionLocationID'), isFalse);
});
@@ -266,7 +267,7 @@ void main() {
});
addTearDown(client.close);
final updated = await client.liveTv.updateRecordingRule('18', const {'startOffsetMinutes': 5});
final updated = await client.liveTvDvr!.updateRecordingRule('18', const {'startOffsetMinutes': 5});
expect(captured.method, 'PUT');
expect(captured.url.path, '/media/subscriptions/18');
@@ -307,7 +308,7 @@ void main() {
});
addTearDown(client.close);
final rules = await client.liveTv.fetchRecordingRules(includeGrabs: true, includeStorage: false);
final rules = await client.liveTvDvr!.fetchRecordingRules(includeGrabs: true, includeStorage: false);
expect(captured.url.path, '/media/subscriptions');
expect(captured.url.queryParameters['includeGrabs'], '1');
@@ -327,7 +328,7 @@ void main() {
});
addTearDown(client.close);
await client.liveTv.cancelGrab('/media/grabbers/operations/grab-1');
await client.liveTvDvr!.cancelGrab('/media/grabbers/operations/grab-1');
expect(captured.method, 'DELETE');
expect(captured.url.path, '/media/grabbers/operations/grab-1');
@@ -360,7 +361,7 @@ void main() {
});
addTearDown(client.close);
final operations = await client.liveTv.fetchScheduledRecordings();
final operations = await client.liveTvDvr!.fetchScheduledRecordings();
expect(operations.single.id, 'grab-1');
expect(operations.single.operationKey, '/media/grabbers/operations/grab-1');