From 359a6bf02a7db4058391766ba7de15d4127cb85d Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 12 Jul 2026 06:34:57 +0200 Subject: [PATCH] refactor(live-tv): split optional DVR support --- lib/media/live_tv_support.dart | 26 +- lib/media/media_server_client.dart | 10 +- lib/providers/multi_server_provider.dart | 3 +- lib/screens/livetv/live_tv_screen.dart | 14 +- .../livetv/live_tv_show_schedule_screen.dart | 3 +- .../livetv/livetv_recording_actions.dart | 17 +- lib/screens/livetv/program_details_sheet.dart | 9 +- lib/screens/livetv/record_options_sheet.dart | 9 +- lib/screens/livetv/tabs/guide_tab.dart | 7 +- lib/screens/livetv/tabs/recordings_tab.dart | 7 +- lib/services/jellyfin_client.dart | 8 - .../jellyfin_client/parts/live_tv.dart | 188 +--------- lib/services/plex_client/parts/live_tv.dart | 330 +++++------------- .../live_tv_capability_contract_test.dart | 134 +++++++ test/services/plex_live_tv_support_test.dart | 19 +- 15 files changed, 305 insertions(+), 479 deletions(-) create mode 100644 test/services/live_tv_capability_contract_test.dart diff --git a/lib/media/live_tv_support.dart b/lib/media/live_tv_support.dart index 42012140..20c0e176 100644 --- a/lib/media/live_tv_support.dart +++ b/lib/media/live_tv_support.dart @@ -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 isAvailable(); - /// Plex returns one entry per configured DVR; Jellyfin returns an empty - /// list (it has no per-DVR partitioning). - Future> 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 setFavoriteChannels(List 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> fetchDvrs(); Future fetchLiveTvServerStatus(); Future fetchDvr(String dvrId); Future> createDvr({ diff --git a/lib/media/media_server_client.dart b/lib/media/media_server_client.dart index 0b7e0c72..3e50f567 100644 --- a/lib/media/media_server_client.dart +++ b/lib/media/media_server_client.dart @@ -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. diff --git a/lib/providers/multi_server_provider.dart b/lib/providers/multi_server_provider.dart index e538c799..879fa4a3 100644 --- a/lib/providers/multi_server_provider.dart +++ b/lib/providers/multi_server_provider.dart @@ -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 [] : await dvr.fetchDvrs(); if (dvrs.isNotEmpty) { // Plex: one entry per DVR with its own lineup. for (final dvr in dvrs) { diff --git a/lib/screens/livetv/live_tv_screen.dart b/lib/screens/livetv/live_tv_screen.dart index 2cd7fd55..a70970d9 100644 --- a/lib/screens/livetv/live_tv_screen.dart +++ b/lib/screens/livetv/live_tv_screen.dart @@ -167,7 +167,7 @@ class _LiveTvScreenState extends State final futures = >[]; 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 Future _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 final futures = >[]; 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 Future _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 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; diff --git a/lib/screens/livetv/live_tv_show_schedule_screen.dart b/lib/screens/livetv/live_tv_show_schedule_screen.dart index 7bb68110..106eff2e 100644 --- a/lib/screens/livetv/live_tv_show_schedule_screen.dart +++ b/lib/screens/livetv/live_tv_show_schedule_screen.dart @@ -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 /// lookup is needed. bool get _canRecord { final client = context.read().getClientForServer(ServerId(widget.serverId)); - return client != null && client.capabilities.liveTvDvr; + return client?.liveTvDvr != null; } Future _onRecordShow() async { diff --git a/lib/screens/livetv/livetv_recording_actions.dart b/lib/screens/livetv/livetv_recording_actions.dart index 29c53f1f..1c59f780 100644 --- a/lib/screens/livetv/livetv_recording_actions.dart +++ b/lib/screens/livetv/livetv_recording_actions.dart @@ -27,8 +27,9 @@ enum RecordOutcome { scheduled, updated, alreadyScheduled, adminRequired, target /// - 409 (duplicate): surface "Already scheduled" via info snackbar. /// - Other: generic failure snackbar. Future 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 recordProgram(BuildContext context, MediaServerClient cli List 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 editRecordingRule(BuildContext context, MediaServerClient } Future 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 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 confirmCancelGrab(BuildContext context, MediaServerClient client, M } Future 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 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); } diff --git a/lib/screens/livetv/program_details_sheet.dart b/lib/screens/livetv/program_details_sheet.dart index aa6970bb..a059fc97 100644 --- a/lib/screens/livetv/program_details_sheet.dart +++ b/lib/screens/livetv/program_details_sheet.dart @@ -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(() { diff --git a/lib/screens/livetv/record_options_sheet.dart b/lib/screens/livetv/record_options_sheet.dart index 541fe1d2..86301796 100644 --- a/lib/screens/livetv/record_options_sheet.dart +++ b/lib/screens/livetv/record_options_sheet.dart @@ -190,6 +190,11 @@ class _RecordOptionsContentState extends State<_RecordOptionsContent> { } Future _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); diff --git a/lib/screens/livetv/tabs/guide_tab.dart b/lib/screens/livetv/tabs/guide_tab.dart index 39b88061..8beb6826 100644 --- a/lib/screens/livetv/tabs/guide_tab.dart +++ b/lib/screens/livetv/tabs/guide_tab.dart @@ -402,9 +402,10 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi required ServerId serverId, required Set 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 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); diff --git a/lib/screens/livetv/tabs/recordings_tab.dart b/lib/screens/livetv/tabs/recordings_tab.dart index 4188b3a2..650cd1ab 100644 --- a/lib/screens/livetv/tabs/recordings_tab.dart +++ b/lib/screens/livetv/tabs/recordings_tab.dart @@ -124,10 +124,11 @@ class RecordingsTabState extends State { 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); diff --git a/lib/services/jellyfin_client.dart b/lib/services/jellyfin_client.dart index a91c1bd5..50655b1c 100644 --- a/lib/services/jellyfin_client.dart +++ b/lib/services/jellyfin_client.dart @@ -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'; diff --git a/lib/services/jellyfin_client/parts/live_tv.dart b/lib/services/jellyfin_client/parts/live_tv.dart index 77ff4a6c..044129d9 100644 --- a/lib/services/jellyfin_client/parts/live_tv.dart +++ b/lib/services/jellyfin_client/parts/live_tv.dart @@ -145,16 +145,12 @@ class _JellyfinLiveTvSupport implements LiveTvSupport { final JellyfinClient _client; _JellyfinLiveTvSupport(this._client); - Future _unsupported() 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 isAvailable() => _client.hasLiveTv(); - @override - Future> fetchDvrs() async => const []; - @override Future> 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 fetchLiveTvServerStatus() => _unsupported(); - - @override - Future fetchDvr(String dvrId) => _unsupported(); - - @override - Future> createDvr({ - required List devices, - required List lineups, - String? language, - String? country, - String? postalCode, - }) => _unsupported(); - - @override - Future deleteDvr(String dvrId) => _unsupported(); - - @override - Future updateDvrPrefs(String dvrId, Map prefs) => _unsupported(); - - @override - Future attachDeviceToDvr(String dvrId, String deviceId) => _unsupported(); - - @override - Future detachDeviceFromDvr(String dvrId, String deviceId) => _unsupported(); - - @override - Future addLineupToDvr(String dvrId, String lineupUri) => _unsupported(); - - @override - Future removeLineupFromDvr(String dvrId, String lineupUri) => _unsupported(); - - @override - Future> reloadGuide(String dvrId) => _unsupported(); - - @override - Future cancelGuideReload(String dvrId) => _unsupported(); - - @override - Future> fetchGrabbers({String? protocol}) => _unsupported(); - - @override - Future> fetchGrabberDevices() => _unsupported(); - - @override - Future>> discoverGrabberDevices() => _unsupported(); - - @override - Future fetchGrabberDevice(String deviceId) => _unsupported(); - - @override - Future addGrabberDevice(String uri, {String? grabberId}) => _unsupported(); - - @override - Future updateGrabberDevice(String deviceId, {bool? enabled, String? title}) => _unsupported(); - - @override - Future deleteGrabberDevice(String deviceId) => _unsupported(); - - @override - Future> fetchGrabberDeviceChannels(String deviceId) => _unsupported(); - - @override - Future> scanGrabberDevice( - String deviceId, { - String? source, - Map prefs = const {}, - String? network, - String? country, - }) => _unsupported(); - - @override - Future cancelGrabberDeviceScan(String deviceId) => _unsupported(); - - @override - Future saveGrabberDeviceChannelMap(String deviceId, MediaGrabberChannelMapRequest request) => - _unsupported(); - - @override - Future updateGrabberDevicePrefs(String deviceId, Map prefs) => _unsupported(); - - @override - String buildGrabberDeviceThumbUrl(String deviceId, int version) => _unsupportedSync(); - - @override - Future> fetchEpgCountries() => _unsupported(); - - @override - Future> fetchEpgLanguages() => _unsupported(); - - @override - Future> fetchEpgRegions(String country, String epgId) => _unsupported(); - - @override - Future fetchEpgLineups(String country, String epgId, {String? postalCode, String? region}) => - _unsupported(); - - @override - Future> fetchEpgChannelsForLineup(String lineupUri) => _unsupported(); - - @override - Future> fetchEpgChannelsForLineups(List lineupUris) => _unsupported(); - - @override - Future> computeEpgChannelMap({required String deviceUri, required String lineupUri}) => - _unsupported(); - - @override - Future?>> findBestLineup({ - required String deviceUri, - required String lineupGroupUri, - }) => _unsupported(); - - @override - Future> getSubscriptionTemplate(String guid) => _unsupported(); - - @override - Future> fetchRecordingRules({bool includeGrabs = true, bool includeStorage = true}) => - _unsupported(); - - @override - Future fetchRecordingRule( - String subscriptionId, { - bool includeGrabs = true, - bool includeStorage = true, - }) => _unsupported(); - - @override - Future createRecordingRule(MediaSubscriptionCreateRequest request) => _unsupported(); - - @override - Future updateRecordingRule(String subscriptionId, Map prefs) => _unsupported(); - - @override - Future deleteRecordingRule(String subscriptionId) => _unsupported(); - - @override - Future moveRecordingRule(String subscriptionId, {String? afterSubscriptionId}) => _unsupported(); - - @override - Future processRecordingRules() => _unsupported(); - - @override - Future> fetchScheduledRecordings() => _unsupported(); - - @override - Future cancelGrab(String operationId) => _unsupported(); - - @override - Future> fetchSubscriptionMapping({ - required String providerId, - required List ratingKeys, - bool includeStorage = true, - }) => _unsupported(); - - @override - Future> fetchMediaProviders() => _unsupported(); - - @override - Future registerMediaProvider(String url) => _unsupported(); - - @override - Future refreshMediaProviders() => _unsupported(); - - @override - Future unregisterMediaProvider(String providerId) => _unsupported(); - - @override - Future> fetchLiveTvSessionsDetailed() => _unsupported(); - - @override - Future fetchLiveTvSession(String sessionId) => _unsupported(); - - @override - Uri buildNotificationWebSocketUri({List? filters}) => _unsupportedSync(); - - @override - Uri buildNotificationEventSourceUri({List? filters}) => _unsupportedSync(); } /// A Jellyfin live playback session: one negotiated direct-stream URL plus diff --git a/lib/services/plex_client/parts/live_tv.dart b/lib/services/plex_client/parts/live_tv.dart index c8b4d357..eb23a4fb 100644 --- a/lib/services/plex_client/parts/live_tv.dart +++ b/lib/services/plex_client/parts/live_tv.dart @@ -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> getDvrs() async { + @override + Future> fetchDvrs() async { return _wrapListApiCall(() => _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 hasDvr() async { - final dvrs = await getDvrs(); + final dvrs = await fetchDvrs(); return dvrs.isNotEmpty; } - Future getLiveTvServerStatus() async { + @override + Future fetchLiveTvServerStatus() async { final response = await _getWithFailover('/'); final container = _getMediaContainer(response); return LiveTvServerStatus.fromJson(container ?? const {}); } - Future getDvr(String dvrId) async { + @override + Future 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> createDvr({ required List devices, required List lineups, @@ -237,32 +241,41 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin { ); } + @override Future deleteDvr(String dvrId) => _expectOk(() => _http.delete('/livetv/dvrs/$dvrId')); + @override Future updateDvrPrefs(String dvrId, Map prefs) => _expectOk(() => _http.put('/livetv/dvrs/$dvrId/prefs', queryParameters: prefs)); + @override Future attachDeviceToDvr(String dvrId, String deviceId) => _expectOk(() => _http.put('/livetv/dvrs/$dvrId/devices/$deviceId')); + @override Future detachDeviceFromDvr(String dvrId, String deviceId) => _expectOk(() => _http.delete('/livetv/dvrs/$dvrId/devices/$deviceId')); + @override Future addLineupToDvr(String dvrId, String lineupUri) => _expectOk(() => _http.put('/livetv/dvrs/$dvrId/lineups', queryParameters: {'lineup': lineupUri})); + @override Future removeLineupFromDvr(String dvrId, String lineupUri) => _expectOk(() => _http.delete('/livetv/dvrs/$dvrId/lineups', queryParameters: {'lineup': lineupUri})); + @override Future> 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 cancelGuideReload(String dvrId) => _expectOk(() => _http.delete('/livetv/dvrs/$dvrId/reloadGuide')); - Future> getGrabbers({String? protocol}) async { + @override + Future> 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> getGrabberDevices() async { + @override + Future> fetchGrabberDevices() async { final response = await _getWithFailover('/media/grabbers/devices'); return _extractContainerList(response, const ['Device', 'Devices'], MediaGrabberDevice.fromJson); } + @override Future>> 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 getGrabberDevice(String deviceId) async { + @override + Future fetchGrabberDevice(String deviceId) async { final response = await _getWithFailover('/media/grabbers/devices/$deviceId'); return _extractFirst(response, const ['Device', 'Devices'], MediaGrabberDevice.fromJson); } + @override Future 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 updateGrabberDevice(String deviceId, {bool? enabled, String? title}) => _expectOk( () => _http.put( '/media/grabbers/devices/$deviceId', @@ -308,14 +326,17 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin { ), ); + @override Future deleteGrabberDevice(String deviceId) => _expectOk(() => _http.delete('/media/grabbers/devices/$deviceId')); - Future> getGrabberDeviceChannels(String deviceId) async { + @override + Future> fetchGrabberDeviceChannels(String deviceId) async { final response = await _getWithFailover('/media/grabbers/devices/$deviceId/channels'); return _extractContainerList(response, const ['DeviceChannel'], MediaGrabberDeviceChannel.fromJson); } + @override Future> scanGrabberDevice( String deviceId, { String? source, @@ -340,12 +361,14 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin { ); } + @override Future 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 saveGrabberDeviceChannelMap( String deviceId, MediaGrabberChannelMapRequest request, @@ -362,28 +385,34 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin { return _extractFirst(response, const ['Device', 'Devices'], MediaGrabberDevice.fromJson); } + @override Future updateGrabberDevicePrefs(String deviceId, Map 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> getEpgCountries() async { + @override + Future> fetchEpgCountries() async { final response = await _getWithFailover('/livetv/epg/countries'); return _extractContainerList(response, const ['Country'], LiveTvCountry.fromJson); } - Future> getEpgLanguages() async { + @override + Future> fetchEpgLanguages() async { final response = await _getWithFailover('/livetv/epg/languages'); return _extractContainerList(response, const ['Language'], LiveTvLanguage.fromJson); } - Future> getEpgRegions(String country, String epgId) async { + @override + Future> fetchEpgRegions(String country, String epgId) async { final response = await _getWithFailover('/livetv/epg/countries/$country/$epgId/regions'); return _extractContainerList(response, const ['Region'], LiveTvRegion.fromJson); } - Future getEpgLineups(String country, String epgId, {String? postalCode, String? region}) async { + @override + Future 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> getEpgChannelsForLineup(String lineupUri) async { + @override + Future> 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> getEpgChannelsForLineups(List lineupUris) async { + @override + Future> fetchEpgChannelsForLineups(List lineupUris) async { final response = await _getWithFailover('/livetv/epg/lineupchannels', queryParameters: {'lineup': lineupUris}); return _extractContainerList(response, const ['Lineup'], LiveTvLineup.fromJson); } + @override Future> 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?>> findBestLineup({ required String deviceUri, required String lineupGroupUri, @@ -696,12 +729,14 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin { } } + @override Future> getSubscriptionTemplate(String guid) async { final response = await _getWithFailover('/media/subscriptions/template', queryParameters: {'guid': guid}); return _extractContainerList(response, const ['SubscriptionTemplate'], SubscriptionTemplate.fromJson); } - Future> getRecordingRules({bool includeGrabs = true, bool includeStorage = true}) async { + @override + Future> 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 getRecordingRule( + @override + Future 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 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 updateRecordingRule(String subscriptionId, Map 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 deleteRecordingRule(String subscriptionId) => _expectOk(() => _http.delete('/media/subscriptions/$subscriptionId')); + @override Future 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 processRecordingRules() => _expectOk(() => _http.post('/media/subscriptions/process')); - Future> getScheduledRecordings() async { + @override + Future> fetchScheduledRecordings() async { final response = await _getWithFailover('/media/subscriptions/scheduled'); return _extractContainerList(response, const ['MediaGrabOperation'], MediaGrabOperation.fromJson); } + @override Future 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> getSubscriptionMapping({ + @override + Future> fetchSubscriptionMapping({ required String providerId, required List ratingKeys, bool includeStorage = true, @@ -780,16 +824,20 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin { return _extractContainerList(response, const ['MediaSubscription'], MediaSubscription.fromJson); } - Future> getMediaProviders() async { + @override + Future> fetchMediaProviders() async { final response = await _getWithFailover('/media/providers'); return _extractContainerList(response, const ['MediaProvider'], MediaProviderInfo.fromJson); } + @override Future registerMediaProvider(String url) => _expectOk(() => _http.post('/media/providers', queryParameters: {'url': url})); + @override Future refreshMediaProviders() => _expectOk(() => _http.post('/media/providers/refresh')); + @override Future 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> getLiveTvSessionsDetailed() async { + @override + Future> fetchLiveTvSessionsDetailed() async { final response = await _getWithFailover('/livetv/sessions'); return _extractContainerList(response, const [ 'LiveTVSession', @@ -1070,7 +1119,8 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin { ], LiveTvSession.fromJson); } - Future getLiveTvSession(String sessionId) async { + @override + Future 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? filters}) { final base = Uri.parse(config.baseUrl); return base.replace( @@ -1092,6 +1143,7 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin { ); } + @override Uri buildNotificationEventSourceUri({List? 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 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> getFavoriteChannels() async { + @override + Future> 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 setFavoriteChannels(List 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 isAvailable() => _client.hasDvr(); + LiveTvDvrSupport get dvr => this as LiveTvDvrSupport; @override - Future> fetchDvrs() => _client.getDvrs(); + Future isAvailable() => hasDvr(); @override - Future> fetchChannels({String? lineup}) => _client.getEpgChannels(lineup: lineup); + Future> fetchChannels({String? lineup}) => getEpgChannels(lineup: lineup); @override Future> 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 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> fetchFavoriteChannels() => _client.getFavoriteChannels(); - - @override - Future setFavoriteChannels(List channels) => _client.setFavoriteChannels(channels); - - @override - Future fetchLiveTvServerStatus() => _client.getLiveTvServerStatus(); - - @override - Future fetchDvr(String dvrId) => _client.getDvr(dvrId); - - @override - Future> createDvr({ - required List devices, - required List lineups, - String? language, - String? country, - String? postalCode, - }) => _client.createDvr( - devices: devices, - lineups: lineups, - language: language, - country: country, - postalCode: postalCode, - ); - - @override - Future deleteDvr(String dvrId) => _client.deleteDvr(dvrId); - - @override - Future updateDvrPrefs(String dvrId, Map prefs) => _client.updateDvrPrefs(dvrId, prefs); - - @override - Future attachDeviceToDvr(String dvrId, String deviceId) => _client.attachDeviceToDvr(dvrId, deviceId); - - @override - Future detachDeviceFromDvr(String dvrId, String deviceId) => _client.detachDeviceFromDvr(dvrId, deviceId); - - @override - Future addLineupToDvr(String dvrId, String lineupUri) => _client.addLineupToDvr(dvrId, lineupUri); - - @override - Future removeLineupFromDvr(String dvrId, String lineupUri) => _client.removeLineupFromDvr(dvrId, lineupUri); - - @override - Future> reloadGuide(String dvrId) => _client.reloadGuide(dvrId); - - @override - Future cancelGuideReload(String dvrId) => _client.cancelGuideReload(dvrId); - - @override - Future> fetchGrabbers({String? protocol}) => _client.getGrabbers(protocol: protocol); - - @override - Future> fetchGrabberDevices() => _client.getGrabberDevices(); - - @override - Future>> discoverGrabberDevices() => _client.discoverGrabberDevices(); - - @override - Future fetchGrabberDevice(String deviceId) => _client.getGrabberDevice(deviceId); - - @override - Future addGrabberDevice(String uri, {String? grabberId}) => - _client.addGrabberDevice(uri, grabberId: grabberId); - - @override - Future updateGrabberDevice(String deviceId, {bool? enabled, String? title}) => - _client.updateGrabberDevice(deviceId, enabled: enabled, title: title); - - @override - Future deleteGrabberDevice(String deviceId) => _client.deleteGrabberDevice(deviceId); - - @override - Future> fetchGrabberDeviceChannels(String deviceId) => - _client.getGrabberDeviceChannels(deviceId); - - @override - Future> scanGrabberDevice( - String deviceId, { - String? source, - Map prefs = const {}, - String? network, - String? country, - }) => _client.scanGrabberDevice(deviceId, source: source, prefs: prefs, network: network, country: country); - - @override - Future cancelGrabberDeviceScan(String deviceId) => _client.cancelGrabberDeviceScan(deviceId); - - @override - Future saveGrabberDeviceChannelMap(String deviceId, MediaGrabberChannelMapRequest request) => - _client.saveGrabberDeviceChannelMap(deviceId, request); - - @override - Future updateGrabberDevicePrefs(String deviceId, Map prefs) => - _client.updateGrabberDevicePrefs(deviceId, prefs); - - @override - String buildGrabberDeviceThumbUrl(String deviceId, int version) => - _client.buildGrabberDeviceThumbUrl(deviceId, version); - - @override - Future> fetchEpgCountries() => _client.getEpgCountries(); - - @override - Future> fetchEpgLanguages() => _client.getEpgLanguages(); - - @override - Future> fetchEpgRegions(String country, String epgId) => _client.getEpgRegions(country, epgId); - - @override - Future fetchEpgLineups(String country, String epgId, {String? postalCode, String? region}) => - _client.getEpgLineups(country, epgId, postalCode: postalCode, region: region); - - @override - Future> fetchEpgChannelsForLineup(String lineupUri) => _client.getEpgChannelsForLineup(lineupUri); - - @override - Future> fetchEpgChannelsForLineups(List lineupUris) => - _client.getEpgChannelsForLineups(lineupUris); - - @override - Future> computeEpgChannelMap({required String deviceUri, required String lineupUri}) => - _client.computeEpgChannelMap(deviceUri: deviceUri, lineupUri: lineupUri); - - @override - Future?>> findBestLineup({ - required String deviceUri, - required String lineupGroupUri, - }) => _client.findBestLineup(deviceUri: deviceUri, lineupGroupUri: lineupGroupUri); - - @override - Future> getSubscriptionTemplate(String guid) => _client.getSubscriptionTemplate(guid); - - @override - Future> fetchRecordingRules({bool includeGrabs = true, bool includeStorage = true}) => - _client.getRecordingRules(includeGrabs: includeGrabs, includeStorage: includeStorage); - - @override - Future fetchRecordingRule( - String subscriptionId, { - bool includeGrabs = true, - bool includeStorage = true, - }) => _client.getRecordingRule(subscriptionId, includeGrabs: includeGrabs, includeStorage: includeStorage); - - @override - Future createRecordingRule(MediaSubscriptionCreateRequest request) => - _client.createRecordingRule(request); - - @override - Future updateRecordingRule(String subscriptionId, Map prefs) => - _client.updateRecordingRule(subscriptionId, prefs); - - @override - Future deleteRecordingRule(String subscriptionId) => _client.deleteRecordingRule(subscriptionId); - - @override - Future moveRecordingRule(String subscriptionId, {String? afterSubscriptionId}) => - _client.moveRecordingRule(subscriptionId, afterSubscriptionId: afterSubscriptionId); - - @override - Future processRecordingRules() => _client.processRecordingRules(); - - @override - Future> fetchScheduledRecordings() => _client.getScheduledRecordings(); - - @override - Future cancelGrab(String operationId) => _client.cancelGrab(operationId); - - @override - Future> fetchSubscriptionMapping({ - required String providerId, - required List ratingKeys, - bool includeStorage = true, - }) => _client.getSubscriptionMapping(providerId: providerId, ratingKeys: ratingKeys, includeStorage: includeStorage); - - @override - Future> fetchMediaProviders() => _client.getMediaProviders(); - - @override - Future registerMediaProvider(String url) => _client.registerMediaProvider(url); - - @override - Future refreshMediaProviders() => _client.refreshMediaProviders(); - - @override - Future unregisterMediaProvider(String providerId) => _client.unregisterMediaProvider(providerId); - - @override - Future> fetchLiveTvSessionsDetailed() => _client.getLiveTvSessionsDetailed(); - - @override - Future fetchLiveTvSession(String sessionId) => _client.getLiveTvSession(sessionId); - - @override - Uri buildNotificationWebSocketUri({List? filters}) => _client.buildNotificationWebSocketUri(filters: filters); - - @override - Uri buildNotificationEventSourceUri({List? filters}) => - _client.buildNotificationEventSourceUri(filters: filters); } /// A tuned Plex DVR transcode session. Holds the tune outputs diff --git a/test/services/live_tv_capability_contract_test.dart b/test/services/live_tv_capability_contract_test.dart new file mode 100644 index 00000000..885a3891 --- /dev/null +++ b/test/services/live_tv_capability_contract_test.dart @@ -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 body) => + http.Response(jsonEncode(body), 200, headers: {'content-type': 'application/json'}); + + test('Plex exposes one centralized Live TV DVR adapter', () async { + final requests = []; + 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 = []; + 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 = []; + 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')]), + ); + }); +} diff --git a/test/services/plex_live_tv_support_test.dart b/test/services/plex_live_tv_support_test.dart index 525026a6..4dc81824 100644 --- a/test/services/plex_live_tv_support_test.dart +++ b/test/services/plex_live_tv_support_test.dart @@ -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');