From 87db591792d259c4f7f69729fd1d9f27e56d9e10 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 17 May 2026 06:41:47 +0200 Subject: [PATCH] feat(livetv): group guide by source --- lib/models/livetv_channel.dart | 5 + lib/screens/livetv/live_tv_actions_mixin.dart | 5 + lib/screens/livetv/live_tv_screen.dart | 16 ++ .../livetv/live_tv_show_schedule_screen.dart | 2 +- lib/screens/livetv/tabs/guide_tab.dart | 166 ++++++++++++++++-- lib/screens/livetv/tabs/whats_on_tab.dart | 4 +- .../jellyfin_client/parts/live_tv.dart | 2 + lib/utils/live_tv_grouping.dart | 83 +++++++++ lib/utils/live_tv_matching.dart | 42 +++++ test/utils/live_tv_grouping_test.dart | 82 +++++++++ test/utils/live_tv_matching_test.dart | 39 ++++ 11 files changed, 430 insertions(+), 16 deletions(-) create mode 100644 lib/utils/live_tv_grouping.dart create mode 100644 lib/utils/live_tv_matching.dart create mode 100644 test/utils/live_tv_grouping_test.dart create mode 100644 test/utils/live_tv_matching_test.dart diff --git a/lib/models/livetv_channel.dart b/lib/models/livetv_channel.dart index 212e89b5..ac19e497 100644 --- a/lib/models/livetv_channel.dart +++ b/lib/models/livetv_channel.dart @@ -75,6 +75,8 @@ class LiveTvChannel with MultiServerFields { @JsonKey(includeFromJson: false, includeToJson: false) final String? liveDvrKey; @JsonKey(includeFromJson: false, includeToJson: false) + final String? liveTvSourceTitle; + @JsonKey(includeFromJson: false, includeToJson: false) final String? favoriteSource; @JsonKey(includeFromJson: false, includeToJson: false) final String? favoriteStoreKey; @@ -94,6 +96,7 @@ class LiveTvChannel with MultiServerFields { this.serverId, this.serverName, this.liveDvrKey, + this.liveTvSourceTitle, this.favoriteSource, this.favoriteStoreKey, }); @@ -104,6 +107,7 @@ class LiveTvChannel with MultiServerFields { String? serverId, String? serverName, String? liveDvrKey, + String? liveTvSourceTitle, String? favoriteSource, String? favoriteStoreKey, }) { @@ -122,6 +126,7 @@ class LiveTvChannel with MultiServerFields { serverId: serverId ?? this.serverId, serverName: serverName ?? this.serverName, liveDvrKey: liveDvrKey ?? this.liveDvrKey, + liveTvSourceTitle: liveTvSourceTitle ?? this.liveTvSourceTitle, favoriteSource: favoriteSource ?? this.favoriteSource, favoriteStoreKey: favoriteStoreKey ?? this.favoriteStoreKey, ); diff --git a/lib/screens/livetv/live_tv_actions_mixin.dart b/lib/screens/livetv/live_tv_actions_mixin.dart index 667c859f..a2e21a29 100644 --- a/lib/screens/livetv/live_tv_actions_mixin.dart +++ b/lib/screens/livetv/live_tv_actions_mixin.dart @@ -4,6 +4,7 @@ import 'package:provider/provider.dart'; import '../../models/livetv_channel.dart'; import '../../models/livetv_program.dart'; import '../../providers/multi_server_provider.dart'; +import '../../utils/live_tv_matching.dart'; import '../../utils/live_tv_player_navigation.dart'; import '../../utils/media_image_helper.dart'; import 'program_details_sheet.dart'; @@ -24,6 +25,10 @@ mixin LiveTvActionsMixin on State { }).firstOrNull; } + LiveTvChannel? findChannelForProgram(LiveTvProgram program) { + return liveTvChannels.where((channel) => liveTvProgramMatchesChannel(program, channel)).firstOrNull; + } + /// Start live playback for [channel] on its owning server. /// /// Both backends route through the live-TV navigator so the player diff --git a/lib/screens/livetv/live_tv_screen.dart b/lib/screens/livetv/live_tv_screen.dart index 4a5cce41..bd1461ca 100644 --- a/lib/screens/livetv/live_tv_screen.dart +++ b/lib/screens/livetv/live_tv_screen.dart @@ -248,6 +248,20 @@ class _LiveTvScreenState extends State return _extractEnabledChannelKeys(matching.isNotEmpty ? matching : serverInfo.dvrs); } + String? _sourceTitleForServerInfo(LiveTvServerInfo serverInfo) { + for (final dvr in serverInfo.dvrs) { + if (dvr.key == serverInfo.dvrKey) { + return _nonEmpty(dvr.lineupTitle) ?? _nonEmpty(dvr.lineupURL) ?? _nonEmpty(dvr.lineup); + } + } + return _nonEmpty(serverInfo.lineup); + } + + String? _nonEmpty(String? value) { + final trimmed = value?.trim(); + return trimmed == null || trimmed.isEmpty ? null : trimmed; + } + Future _loadChannels() async { if (!mounted) return; setState(() { @@ -296,6 +310,7 @@ class _LiveTvScreenState extends State final liveTv = genericClient.liveTv; final source = await liveTv.buildFavoriteChannelSource(lineup: serverInfo.lineup); + final sourceTitle = _sourceTitleForServerInfo(serverInfo); final storeKey = liveTv.favoriteStoreKey; final liveServerKey = _liveServerScopeKey(serverInfo); _favoriteSourceByLiveServer[liveServerKey] = source; @@ -314,6 +329,7 @@ class _LiveTvScreenState extends State if (enabledKeys != null && !enabledKeys.contains(channel.key)) continue; final scopedChannel = channel.copyWith( liveDvrKey: serverInfo.dvrKey, + liveTvSourceTitle: sourceTitle, favoriteSource: source, favoriteStoreKey: storeKey, ); diff --git a/lib/screens/livetv/live_tv_show_schedule_screen.dart b/lib/screens/livetv/live_tv_show_schedule_screen.dart index ee2c1c7b..1d0cde27 100644 --- a/lib/screens/livetv/live_tv_show_schedule_screen.dart +++ b/lib/screens/livetv/live_tv_show_schedule_screen.dart @@ -137,7 +137,7 @@ class _LiveTvShowScheduleScreenState extends State SliverList( delegate: SliverChildBuilderDelegate((context, index) { final program = _programs[index]; - final channel = findChannel(program.channelIdentifier); + final channel = findChannelForProgram(program); void onTap() { if (program.isCurrentlyAiring && channel != null) { tuneChannel(channel); diff --git a/lib/screens/livetv/tabs/guide_tab.dart b/lib/screens/livetv/tabs/guide_tab.dart index 8f88afe6..02127864 100644 --- a/lib/screens/livetv/tabs/guide_tab.dart +++ b/lib/screens/livetv/tabs/guide_tab.dart @@ -18,6 +18,8 @@ import '../../../providers/multi_server_provider.dart'; import '../../../media/media_server_client.dart'; import '../../../utils/app_logger.dart'; import '../../../utils/formatters.dart'; +import '../../../utils/live_tv_grouping.dart'; +import '../../../utils/live_tv_matching.dart'; import '../../../utils/media_image_helper.dart'; import '../../../utils/live_tv_player_navigation.dart'; import '../../../widgets/app_icon.dart'; @@ -47,10 +49,28 @@ class GuideTab extends StatefulWidget { enum _GuideZone { timeNav, grid } +sealed class _GuideRow { + const _GuideRow(); +} + +final class _GuideSourceHeaderRow extends _GuideRow { + final String label; + + const _GuideSourceHeaderRow({required this.label}); +} + +final class _GuideChannelRow extends _GuideRow { + final LiveTvChannel channel; + final int channelIndex; + + const _GuideChannelRow({required this.channel, required this.channelIndex}); +} + class GuideTabState extends State with MountedSetStateMixin { static const _slotWidth = 180.0; - static const _channelColumnWidth = 100.0; + static const _channelColumnWidth = 132.0; static const _rowHeight = 64.0; + static const _sourceHeaderRowHeight = 40.0; static const _timeHeaderHeight = 40.0; static const _minutesPerSlot = 30; @@ -354,6 +374,52 @@ class GuideTabState extends State with MountedSetStateMixin { return trimmed == null || trimmed.isEmpty ? null : trimmed; } + List<_GuideRow> get _guideRows { + final groups = groupLiveTvChannelsBySource(widget.channels); + if (groups.length <= 1) { + return [ + for (var i = 0; i < widget.channels.length; i++) _GuideChannelRow(channel: widget.channels[i], channelIndex: i), + ]; + } + + final channelIndexes = {}; + for (var i = 0; i < widget.channels.length; i++) { + channelIndexes[widget.channels[i]] = i; + } + + return [ + for (final group in groups) ...[ + _GuideSourceHeaderRow(label: group.label), + for (final channel in group.channels) + _GuideChannelRow(channel: channel, channelIndex: channelIndexes[channel] ?? 0), + ], + ]; + } + + double _guideRowHeight(_GuideRow row) { + return switch (row) { + _GuideSourceHeaderRow() => _sourceHeaderRowHeight, + _GuideChannelRow() => _rowHeight, + }; + } + + double _guideContentHeight(List<_GuideRow> rows) { + var height = 0.0; + for (final row in rows) { + height += _guideRowHeight(row); + } + return height; + } + + double _rowTopForChannelIndex(int channelIndex) { + var top = 0.0; + for (final row in _guideRows) { + if (row is _GuideChannelRow && row.channelIndex == channelIndex) return top; + top += _guideRowHeight(row); + } + return channelIndex * _rowHeight; + } + void _scrollToNow() { WidgetsBinding.instance.addPostFrameCallback((_) { final now = DateTime.now(); @@ -368,7 +434,7 @@ class GuideTabState extends State with MountedSetStateMixin { } List _getProgramsForChannel(LiveTvChannel channel) { - return _programs.where((p) => p.channelIdentifier == channel.key).toList() + return _programs.where((program) => liveTvProgramMatchesChannel(program, channel)).toList() ..sort((a, b) => (a.beginsAt ?? 0).compareTo(b.beginsAt ?? 0)); } @@ -566,7 +632,7 @@ class GuideTabState extends State with MountedSetStateMixin { void _scrollToChannel(int index) { if (!_gridVerticalController.hasClients) return; - final targetTop = index * _rowHeight; + final targetTop = _rowTopForChannelIndex(index); final targetBottom = targetTop + _rowHeight; final viewportTop = _gridVerticalController.offset; final viewportBottom = viewportTop + _gridVerticalController.position.viewportDimension; @@ -631,6 +697,7 @@ class GuideTabState extends State with MountedSetStateMixin { return ValueListenableBuilder( valueListenable: _hasFocusNotifier, builder: (context, hasFocus, child) { + final rows = _guideRows; return Column( children: [ _buildTimeNavigation(theme), @@ -667,10 +734,18 @@ class GuideTabState extends State with MountedSetStateMixin { child: ListView.builder( controller: _channelVerticalController, physics: const NeverScrollableScrollPhysics(), - itemCount: widget.channels.length, - itemExtent: _rowHeight, - itemBuilder: (context, index) => - _buildChannelCell(widget.channels[index], theme, index: index), + itemCount: rows.length, + itemBuilder: (context, index) { + final row = rows[index]; + return switch (row) { + _GuideSourceHeaderRow(:final label) => _buildSourceHeaderCell(label, theme), + _GuideChannelRow(:final channel, :final channelIndex) => _buildChannelCell( + channel, + theme, + index: channelIndex, + ), + }; + }, ), ), Expanded( @@ -692,12 +767,18 @@ class GuideTabState extends State with MountedSetStateMixin { width: _totalGridWidth(), child: ListView.builder( controller: _gridVerticalController, - itemCount: widget.channels.length, - itemExtent: _rowHeight, + itemCount: rows.length, itemBuilder: (context, index) { - final channel = widget.channels[index]; - final programs = _getProgramsForChannel(channel); - return _buildProgramRow(channel, programs, theme, channelIndex: index); + final row = rows[index]; + return switch (row) { + _GuideSourceHeaderRow(:final label) => _buildSourceHeaderGridRow(label, theme), + _GuideChannelRow(:final channel, :final channelIndex) => _buildProgramRow( + channel, + _getProgramsForChannel(channel), + theme, + channelIndex: channelIndex, + ), + }; }, ), ), @@ -730,7 +811,7 @@ class GuideTabState extends State with MountedSetStateMixin { // Hide when scrolled behind the channel column if (left < _channelColumnWidth) return const SizedBox.shrink(); - final gridHeight = _timeHeaderHeight + widget.channels.length * _rowHeight; + final gridHeight = _timeHeaderHeight + _guideContentHeight(_guideRows); return Positioned( left: left, @@ -973,6 +1054,65 @@ class GuideTabState extends State with MountedSetStateMixin { return Row(children: slots); } + Widget _buildSourceHeaderCell(String label, ThemeData theme) { + return Container( + height: _sourceHeaderRowHeight, + padding: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.35), + border: Border( + bottom: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)), + right: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)), + ), + ), + alignment: Alignment.centerLeft, + child: Text( + label, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w700, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ); + } + + Widget _buildSourceHeaderGridRow(String label, ThemeData theme) { + return Container( + height: _sourceHeaderRowHeight, + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.25), + border: Border(bottom: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3))), + ), + child: ClipRect( + child: ListenableBuilder( + listenable: _gridHorizontalController, + builder: (context, child) { + final scrollOffset = _gridHorizontalController.hasClients ? _gridHorizontalController.offset : 0.0; + return Transform.translate(offset: Offset(scrollOffset, 0), child: child); + }, + child: Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Text( + label, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.8), + fontWeight: FontWeight.w700, + letterSpacing: 0.3, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ), + ), + ); + } + Widget _buildChannelCell(LiveTvChannel channel, ThemeData theme, {required int index}) { final multiServer = context.read(); final client = multiServer.getClientForServer(channel.serverId ?? ''); diff --git a/lib/screens/livetv/tabs/whats_on_tab.dart b/lib/screens/livetv/tabs/whats_on_tab.dart index 5f58d9fe..1e55589f 100644 --- a/lib/screens/livetv/tabs/whats_on_tab.dart +++ b/lib/screens/livetv/tabs/whats_on_tab.dart @@ -140,7 +140,7 @@ class WhatsOnTabState extends State with LiveTvActionsMixin with LiveTvActionsMixin showProgramDetails( program: entry.program, - channel: findChannel(entry.program.channelIdentifier), + channel: findChannelForProgram(entry.program), posterThumb: entry.metadata.grandparentThumbPath ?? entry.metadata.thumbPath, posterServerId: entry.metadata.serverId ?? '', ), diff --git a/lib/services/jellyfin_client/parts/live_tv.dart b/lib/services/jellyfin_client/parts/live_tv.dart index fcc63384..f279ec23 100644 --- a/lib/services/jellyfin_client/parts/live_tv.dart +++ b/lib/services/jellyfin_client/parts/live_tv.dart @@ -102,6 +102,8 @@ mixin _JellyfinLiveTvMethods on MediaServerCacheMixin { channelCallSign: json['ChannelCallSign'] as String? ?? json['ChannelName'] as String?, live: json['IsLive'] as bool?, premiere: json['IsPremiere'] as bool?, + serverId: serverId, + serverName: serverName, ); } diff --git a/lib/utils/live_tv_grouping.dart b/lib/utils/live_tv_grouping.dart new file mode 100644 index 00000000..38d61801 --- /dev/null +++ b/lib/utils/live_tv_grouping.dart @@ -0,0 +1,83 @@ +import '../models/livetv_channel.dart'; +import 'live_tv_matching.dart'; + +class LiveTvChannelGroup { + final String key; + final String label; + final List channels; + + const LiveTvChannelGroup({required this.key, required this.label, required this.channels}); + + LiveTvChannelGroup copyWith({String? label}) { + return LiveTvChannelGroup(key: key, label: label ?? this.label, channels: channels); + } +} + +List groupLiveTvChannelsBySource(List channels) { + final order = []; + final bySource = >{}; + final labels = {}; + + for (final channel in channels) { + final key = liveTvChannelSourceKey(channel); + if (!bySource.containsKey(key)) { + order.add(key); + bySource[key] = []; + labels[key] = liveTvChannelSourceLabel(channel); + } + bySource[key]!.add(channel); + } + + final groups = [ + for (final key in order) + LiveTvChannelGroup(key: key, label: labels[key]!, channels: List.unmodifiable(bySource[key]!)), + ]; + + final labelCounts = {}; + for (final group in groups) { + labelCounts[group.label] = (labelCounts[group.label] ?? 0) + 1; + } + + return [ + for (final group in groups) + if ((labelCounts[group.label] ?? 0) > 1) group.copyWith(label: _deduplicatedLabel(group)) else group, + ]; +} + +String liveTvChannelSourceKey(LiveTvChannel channel) { + final serverId = _nonEmpty(channel.serverId) ?? ''; + final providerSource = _nonEmpty(channel.favoriteSource) ?? _nonEmpty(channel.lineup) ?? ''; + final dvrSource = _nonEmpty(channel.liveDvrKey) ?? ''; + return '$serverId\u0000$providerSource\u0000$dvrSource'; +} + +String liveTvChannelSourceLabel(LiveTvChannel channel) { + final serverLabel = _nonEmpty(channel.serverName) ?? _nonEmpty(channel.serverId) ?? 'Live TV'; + final sourceTitle = _nonEmpty(channel.liveTvSourceTitle); + if (sourceTitle == null || sourceTitle == serverLabel) return serverLabel; + return '$serverLabel - $sourceTitle'; +} + +String _deduplicatedLabel(LiveTvChannelGroup group) { + if (group.channels.isEmpty) return group.label; + final first = group.channels.first; + final suffixes = [ + _nonEmpty(first.liveTvSourceTitle), + _nonEmpty(first.liveDvrKey), + liveTvProviderIdentifierForChannel(first), + ]; + String? suffix; + for (final value in suffixes) { + if (value != null && !group.label.contains(value)) { + suffix = value; + break; + } + } + if (suffix == null || group.label.contains(suffix)) return group.label; + return '${group.label} - $suffix'; +} + +String? _nonEmpty(String? value) { + final trimmed = value?.trim(); + return trimmed == null || trimmed.isEmpty ? null : trimmed; +} diff --git a/lib/utils/live_tv_matching.dart b/lib/utils/live_tv_matching.dart new file mode 100644 index 00000000..69f4dc8f --- /dev/null +++ b/lib/utils/live_tv_matching.dart @@ -0,0 +1,42 @@ +import '../models/livetv_channel.dart'; +import '../models/livetv_program.dart'; + +bool liveTvProgramMatchesChannel(LiveTvProgram program, LiveTvChannel channel) { + final programChannel = _nonEmpty(program.channelIdentifier); + if (programChannel == null) return false; + if (programChannel != channel.key && programChannel != channel.identifier) return false; + + if (!_nullableIdsMatch(program.serverId, channel.serverId)) return false; + if (!_nullableIdsMatch(program.liveDvrKey, channel.liveDvrKey)) return false; + + final programProvider = _nonEmpty(program.providerIdentifier); + final channelProvider = liveTvProviderIdentifierForChannel(channel); + if (programProvider != null && channelProvider != null && programProvider != channelProvider) return false; + + return true; +} + +String? liveTvProviderIdentifierForChannel(LiveTvChannel channel) { + final source = _nonEmpty(channel.favoriteSource); + if (source != null) { + final uri = Uri.tryParse(source); + if (uri != null && uri.pathSegments.isNotEmpty) return _nonEmpty(uri.pathSegments.last); + + final slashIndex = source.lastIndexOf('/'); + if (slashIndex >= 0 && slashIndex < source.length - 1) { + return _nonEmpty(source.substring(slashIndex + 1)); + } + } + return _nonEmpty(channel.lineup); +} + +bool _nullableIdsMatch(String? a, String? b) { + final left = _nonEmpty(a); + final right = _nonEmpty(b); + return left == null || right == null || left == right; +} + +String? _nonEmpty(String? value) { + final trimmed = value?.trim(); + return trimmed == null || trimmed.isEmpty ? null : trimmed; +} diff --git a/test/utils/live_tv_grouping_test.dart b/test/utils/live_tv_grouping_test.dart new file mode 100644 index 00000000..7bf0acea --- /dev/null +++ b/test/utils/live_tv_grouping_test.dart @@ -0,0 +1,82 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/models/livetv_channel.dart'; +import 'package:plezy/utils/live_tv_grouping.dart'; + +LiveTvChannel _channel({ + required String key, + required String serverId, + required String serverName, + required String dvrKey, + required String favoriteSource, + String? sourceTitle, +}) { + return LiveTvChannel( + key: key, + serverId: serverId, + serverName: serverName, + liveDvrKey: dvrKey, + favoriteSource: favoriteSource, + liveTvSourceTitle: sourceTitle, + ); +} + +void main() { + test('groups channels by Live TV source while preserving first source appearance', () { + final firstHome = _channel( + key: '101', + serverId: 'home', + serverName: 'Home Plex', + dvrKey: 'dvr-a', + favoriteSource: 'server://home/provider-a', + sourceTitle: 'Seattle OTA', + ); + final cabin = _channel( + key: '101', + serverId: 'cabin', + serverName: 'Cabin Plex', + dvrKey: 'dvr-a', + favoriteSource: 'server://cabin/provider-b', + sourceTitle: 'Portland OTA', + ); + final secondHome = _channel( + key: '102', + serverId: 'home', + serverName: 'Home Plex', + dvrKey: 'dvr-a', + favoriteSource: 'server://home/provider-a', + sourceTitle: 'Seattle OTA', + ); + + final groups = groupLiveTvChannelsBySource([firstHome, cabin, secondHome]); + + expect(groups.map((group) => group.label), ['Home Plex - Seattle OTA', 'Cabin Plex - Portland OTA']); + expect(groups.first.channels, [firstHome, secondHome]); + expect(groups.last.channels, [cabin]); + }); + + test('keeps DVRs on the same server as separate groups', () { + final channels = [ + _channel( + key: '101', + serverId: 'home', + serverName: 'Home Plex', + dvrKey: 'dvr-a', + favoriteSource: 'server://home/provider-a', + sourceTitle: 'Seattle OTA', + ), + _channel( + key: '101', + serverId: 'home', + serverName: 'Home Plex', + dvrKey: 'dvr-b', + favoriteSource: 'server://home/provider-a', + sourceTitle: 'Seattle OTA', + ), + ]; + + final groups = groupLiveTvChannelsBySource(channels); + + expect(groups, hasLength(2)); + expect(groups.map((group) => group.label), ['Home Plex - Seattle OTA - dvr-a', 'Home Plex - Seattle OTA - dvr-b']); + }); +} diff --git a/test/utils/live_tv_matching_test.dart b/test/utils/live_tv_matching_test.dart new file mode 100644 index 00000000..6b90cb2a --- /dev/null +++ b/test/utils/live_tv_matching_test.dart @@ -0,0 +1,39 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/models/livetv_channel.dart'; +import 'package:plezy/models/livetv_program.dart'; +import 'package:plezy/utils/live_tv_matching.dart'; + +void main() { + test('matches channel by id and server', () { + final program = LiveTvProgram(title: 'News', channelIdentifier: '101', serverId: 'server-a'); + + expect(liveTvProgramMatchesChannel(program, LiveTvChannel(key: '101', serverId: 'server-a')), isTrue); + expect(liveTvProgramMatchesChannel(program, LiveTvChannel(key: '101', serverId: 'server-b')), isFalse); + }); + + test('matches channel identifier fallback', () { + final program = LiveTvProgram(title: 'News', channelIdentifier: 'station-101', serverId: 'server-a'); + final channel = LiveTvChannel(key: '101', identifier: 'station-101', serverId: 'server-a'); + + expect(liveTvProgramMatchesChannel(program, channel), isTrue); + }); + + test('uses provider identifier when duplicate channels exist on one server', () { + final program = LiveTvProgram( + title: 'News', + channelIdentifier: '101', + serverId: 'server-a', + providerIdentifier: 'provider-a', + ); + + final matching = LiveTvChannel(key: '101', serverId: 'server-a', favoriteSource: 'server://machine/provider-a'); + final otherProvider = LiveTvChannel( + key: '101', + serverId: 'server-a', + favoriteSource: 'server://machine/provider-b', + ); + + expect(liveTvProgramMatchesChannel(program, matching), isTrue); + expect(liveTvProgramMatchesChannel(program, otherProvider), isFalse); + }); +}