refactor(live-tv): split optional DVR support
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')]),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user