From a4d4e2e1f827a1c72a72d0a2866a2302d4aa83b8 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 13 Feb 2026 14:16:44 +0100 Subject: [PATCH] fix(tv): query all EPG providers for multi-DVR support --- lib/screens/livetv/live_tv_screen.dart | 8 +- lib/screens/livetv/tabs/guide_tab.dart | 3 +- lib/screens/livetv/tabs/whats_on_tab.dart | 2 + lib/services/plex_client.dart | 202 ++++++++++++---------- 4 files changed, 121 insertions(+), 94 deletions(-) diff --git a/lib/screens/livetv/live_tv_screen.dart b/lib/screens/livetv/live_tv_screen.dart index c744dbfa..1b9515e2 100644 --- a/lib/screens/livetv/live_tv_screen.dart +++ b/lib/screens/livetv/live_tv_screen.dart @@ -107,6 +107,7 @@ class _LiveTvScreenState extends State with SingleTickerProviderSt } final allChannels = []; + final seenChannels = {}; for (final serverInfo in liveTvServers) { try { @@ -114,7 +115,12 @@ class _LiveTvScreenState extends State with SingleTickerProviderSt if (client == null) continue; final channels = await client.getEpgChannels(lineup: serverInfo.lineup); - allChannels.addAll(channels); + for (final channel in channels) { + final dedupKey = '${serverInfo.serverId}:${channel.identifier ?? channel.key}'; + if (seenChannels.add(dedupKey)) { + allChannels.add(channel); + } + } } catch (e) { appLogger.e('Failed to load channels from server ${serverInfo.serverId}', error: e); } diff --git a/lib/screens/livetv/tabs/guide_tab.dart b/lib/screens/livetv/tabs/guide_tab.dart index 37eafd80..3660ac1d 100644 --- a/lib/screens/livetv/tabs/guide_tab.dart +++ b/lib/screens/livetv/tabs/guide_tab.dart @@ -198,8 +198,10 @@ class GuideTabState extends State { final multiServer = context.read(); final liveTvServers = multiServer.liveTvServers; final allPrograms = []; + final queriedServers = {}; for (final serverInfo in liveTvServers) { + if (!queriedServers.add(serverInfo.serverId)) continue; try { final client = multiServer.getClientForServer(serverInfo.serverId); if (client == null) continue; @@ -208,7 +210,6 @@ class GuideTabState extends State { final endEpoch = _gridEnd.millisecondsSinceEpoch ~/ 1000; final programs = await client.getEpgGrid( - lineup: serverInfo.lineup, beginsAt: startEpoch, endsAt: endEpoch, ); diff --git a/lib/screens/livetv/tabs/whats_on_tab.dart b/lib/screens/livetv/tabs/whats_on_tab.dart index a969da95..7b991e93 100644 --- a/lib/screens/livetv/tabs/whats_on_tab.dart +++ b/lib/screens/livetv/tabs/whats_on_tab.dart @@ -76,8 +76,10 @@ class WhatsOnTabState extends State { final multiServer = context.read(); final liveTvServers = multiServer.liveTvServers; final allHubs = []; + final queriedServers = {}; for (final serverInfo in liveTvServers) { + if (!queriedServers.add(serverInfo.serverId)) continue; try { final client = multiServer.getClientForServer(serverInfo.serverId); if (client == null) continue; diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index c0f2a8fc..f8fec703 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -2023,153 +2023,171 @@ class PlexClient { ); } - /// Cached EPG grid endpoint path (discovered from /media/providers) - String? _epgGridEndpoint; + /// Cached EPG providers (discovered from /media/providers) + List<({String identifier, String gridEndpoint})>? _epgProviders; - /// Cached EPG provider identifier (e.g. "tv.plex.providers.epg.xmltv:21") - String? _epgProviderIdentifier; - - /// Discover the EPG grid endpoint from media providers - Future _getEpgGridEndpoint() async { - if (_epgGridEndpoint != null) return _epgGridEndpoint; + /// Discover all EPG providers from media providers + Future> _discoverEpgProviders() async { + if (_epgProviders != null) return _epgProviders!; try { final response = await _dio.get('/media/providers'); final container = _getMediaContainer(response); - if (container == null) return null; + if (container == null) return []; final providers = container['MediaProvider'] as List?; - if (providers == null) return null; + if (providers == null) return []; + + final results = <({String identifier, String gridEndpoint})>[]; for (final provider in providers) { if (provider is! Map) continue; final protocols = provider['protocols'] as String?; if (protocols == null || !protocols.contains('livetv')) continue; - _epgProviderIdentifier = provider['identifier'] as String?; + final identifier = provider['identifier'] as String?; + if (identifier == null) continue; final features = provider['Feature'] as List?; if (features == null) continue; for (final feature in features) { if (feature is! Map) continue; if (feature['type'] == 'grid') { - _epgGridEndpoint = feature['key'] as String?; - appLogger.d('Discovered EPG grid endpoint: $_epgGridEndpoint (provider: $_epgProviderIdentifier)'); - return _epgGridEndpoint; + final gridEndpoint = feature['key'] as String?; + if (gridEndpoint != null) { + results.add((identifier: identifier, gridEndpoint: gridEndpoint)); + appLogger.d('Discovered EPG provider: $identifier (grid: $gridEndpoint)'); + } } } } + + _epgProviders = results; + if (results.isEmpty) { + appLogger.w('No EPG providers found'); + } + return results; } catch (e) { - appLogger.e('Failed to discover EPG grid endpoint', error: e); + appLogger.e('Failed to discover EPG providers', error: e); } - return null; + return []; } /// Get guide/program data for channels (EPG grid data) - /// Discovers the grid endpoint from /media/providers on first call + /// Discovers grid endpoints from /media/providers on first call and queries all providers Future> getEpgGrid({ - String? lineup, int? beginsAt, int? endsAt, }) async { - final gridEndpoint = await _getEpgGridEndpoint(); - if (gridEndpoint == null) { - appLogger.w('No EPG grid endpoint found'); - return []; - } + final providers = await _discoverEpgProviders(); + if (providers.isEmpty) return []; final queryParams = {}; if (beginsAt != null) queryParams['beginsAt>'] = beginsAt; if (endsAt != null) queryParams['endsAt<'] = endsAt; - return _wrapListApiCall( - () => _dio.get(gridEndpoint, queryParameters: queryParams), - (response) { - final container = _getMediaContainer(response); - final programs = []; - if (container != null && container['Metadata'] != null) { - for (final item in container['Metadata'] as List) { - try { - programs.add(LiveTvProgram.fromJson(item as Map)); - } catch (_) {} - } - } - // Some responses nest programs inside Hub entries - if (container != null && container['Hub'] != null) { - for (final hub in container['Hub'] as List) { - if (hub is Map && hub['Metadata'] != null) { - for (final item in hub['Metadata'] as List) { + final allPrograms = []; + + for (final provider in providers) { + try { + final programs = await _wrapListApiCall( + () => _dio.get(provider.gridEndpoint, queryParameters: queryParams), + (response) { + final container = _getMediaContainer(response); + final programs = []; + if (container != null && container['Metadata'] != null) { + for (final item in container['Metadata'] as List) { try { programs.add(LiveTvProgram.fromJson(item as Map)); } catch (_) {} } } - } - } - return programs; - }, - 'Failed to get EPG grid', - ); + // Some responses nest programs inside Hub entries + if (container != null && container['Hub'] != null) { + for (final hub in container['Hub'] as List) { + if (hub is Map && hub['Metadata'] != null) { + for (final item in hub['Metadata'] as List) { + try { + programs.add(LiveTvProgram.fromJson(item as Map)); + } catch (_) {} + } + } + } + } + return programs; + }, + 'Failed to get EPG grid from ${provider.identifier}', + ); + allPrograms.addAll(programs); + } catch (e) { + appLogger.e('Failed to get EPG grid from provider ${provider.identifier}', error: e); + } + } + + return allPrograms; } - /// Get live TV hubs (What's On Now, etc.) from the EPG provider's discover endpoint. + /// Get live TV hubs (What's On Now, etc.) from all EPG providers' discover endpoints. /// Returns hubs with both display metadata and EPG timing/channel data per item. Future> getLiveTvHubs({int count = 12}) async { - await _getEpgGridEndpoint(); - if (_epgProviderIdentifier == null) return []; + final providers = await _discoverEpgProviders(); + if (providers.isEmpty) return []; - try { - final response = await _dio.get( - '/$_epgProviderIdentifier/hubs/discover', - queryParameters: { - 'count': count, - 'includeStations': 1, - 'includeRecentChannels': 1, - 'includeMeta': 1, - 'includeExternalMetadata': 1, - }, - ); + final allHubs = []; - final container = _getMediaContainer(response); - if (container != null && container['Hub'] != null) { - final hubs = []; - for (final hubJson in container['Hub'] as List) { - try { - final metadataList = hubJson['Metadata'] as List?; - if (metadataList == null || metadataList.isEmpty) continue; + for (final provider in providers) { + try { + final response = await _dio.get( + '/${provider.identifier}/hubs/discover', + queryParameters: { + 'count': count, + 'includeStations': 1, + 'includeRecentChannels': 1, + 'includeMeta': 1, + 'includeExternalMetadata': 1, + }, + ); - final entries = []; - for (final itemJson in metadataList) { - if (itemJson is! Map) continue; + final container = _getMediaContainer(response); + if (container != null && container['Hub'] != null) { + for (final hubJson in container['Hub'] as List) { + try { + final metadataList = hubJson['Metadata'] as List?; + if (metadataList == null || metadataList.isEmpty) continue; - // Extract poster/art from Image array before parsing - _extractLiveTvImages(itemJson); + final entries = []; + for (final itemJson in metadataList) { + if (itemJson is! Map) continue; - try { - final metadata = PlexMetadata.fromJson(itemJson) - .copyWith(serverId: serverId, serverName: serverName); - final program = LiveTvProgram.fromJson(itemJson); - entries.add(LiveTvHubEntry(metadata: metadata, program: program)); - } catch (_) {} + // Extract poster/art from Image array before parsing + _extractLiveTvImages(itemJson); + + try { + final metadata = PlexMetadata.fromJson(itemJson) + .copyWith(serverId: serverId, serverName: serverName); + final program = LiveTvProgram.fromJson(itemJson); + entries.add(LiveTvHubEntry(metadata: metadata, program: program)); + } catch (_) {} + } + + if (entries.isNotEmpty) { + allHubs.add(LiveTvHubResult( + title: hubJson['title'] as String? ?? 'Unknown', + hubKey: hubJson['key'] as String? ?? '', + entries: entries, + )); + } + } catch (e) { + appLogger.w('Failed to parse live TV hub', error: e); } - - if (entries.isNotEmpty) { - hubs.add(LiveTvHubResult( - title: hubJson['title'] as String? ?? 'Unknown', - hubKey: hubJson['key'] as String? ?? '', - entries: entries, - )); - } - } catch (e) { - appLogger.w('Failed to parse live TV hub', error: e); } } - return hubs; + } catch (e) { + appLogger.e('Failed to get live TV hubs from provider ${provider.identifier}', error: e); } - } catch (e) { - appLogger.e('Failed to get live TV hubs', error: e); } - return []; + + return allHubs; } /// Extract poster/art URLs from the Image array in EPG metadata items.