From be86a330570c6b777f81f45b71daaf1a5fd1d0a4 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Tue, 26 May 2026 14:45:51 +0200 Subject: [PATCH] fix(discover): hide duplicate continue watching close #1113 --- lib/services/data_aggregation_service.dart | 137 +++++++++++++++++ .../data_aggregation_bridge_test.dart | 144 ++++++++++++++++++ 2 files changed, 281 insertions(+) diff --git a/lib/services/data_aggregation_service.dart b/lib/services/data_aggregation_service.dart index 3fdb0554..659e8ee7 100644 --- a/lib/services/data_aggregation_service.dart +++ b/lib/services/data_aggregation_service.dart @@ -6,6 +6,7 @@ import '../media/media_kind.dart'; import '../media/media_library.dart'; import '../media/media_server_client.dart'; import '../utils/app_logger.dart'; +import '../utils/external_ids.dart'; import '../utils/global_key_utils.dart'; import '../utils/search_relevance.dart'; import 'multi_server_manager.dart'; @@ -78,6 +79,8 @@ class DataAggregationService { return bTime.compareTo(aTime); // Descending (most recent first) }); + filteredOnDeck = await _deduplicateContinueWatching(filteredOnDeck); + // Apply limit if specified final result = limit != null && limit < filteredOnDeck.length ? filteredOnDeck.sublist(0, limit) : filteredOnDeck; @@ -86,6 +89,140 @@ class DataAggregationService { return result; } + Future> _deduplicateContinueWatching(List items) async { + if (items.length < 2) return items; + + final bucketCounts = {}; + for (final item in items) { + final bucket = _continueWatchingTitleBucket(item); + if (bucket == null) continue; + bucketCounts[bucket] = (bucketCounts[bucket] ?? 0) + 1; + } + + final duplicateBuckets = { + for (final entry in bucketCounts.entries) + if (entry.value > 1) entry.key, + }; + if (duplicateBuckets.isEmpty) return items; + + final externalIdLoads = >{}; + final identityKeysByIndex = >{}; + final identityKeyLoads = >[]; + for (var i = 0; i < items.length; i++) { + if (!duplicateBuckets.contains(_continueWatchingTitleBucket(items[i]))) continue; + final index = i; + identityKeyLoads.add( + _continueWatchingIdentityKeys(items[index], externalIdLoads).then((keys) => identityKeysByIndex[index] = keys), + ); + } + await Future.wait(identityKeyLoads); + + final seenKeys = {}; + final result = []; + for (var i = 0; i < items.length; i++) { + final item = items[i]; + if (!duplicateBuckets.contains(_continueWatchingTitleBucket(item))) { + result.add(item); + continue; + } + + final identityKeys = identityKeysByIndex[i] ?? const {}; + if (identityKeys.isEmpty) { + result.add(item); + continue; + } + + if (identityKeys.any(seenKeys.contains)) continue; + + seenKeys.addAll(identityKeys); + result.add(item); + } + + return result; + } + + String? _continueWatchingTitleBucket(MediaItem item) { + final scope = _continueWatchingIdentityScope(item); + if (scope == null) return null; + + final title = switch (item.kind) { + MediaKind.episode || MediaKind.season => item.grandparentTitle ?? item.parentTitle ?? item.title, + _ => item.title, + }; + final normalized = title?.trim().toLowerCase().replaceAll(RegExp(r'\s+'), ' '); + if (normalized == null || normalized.isEmpty) return null; + return '$scope:$normalized'; + } + + Future> _continueWatchingIdentityKeys( + MediaItem item, + Map> externalIdLoads, + ) async { + final scope = _continueWatchingIdentityScope(item); + if (scope == null) return const {}; + + final keys = {}; + final serverId = item.serverId; + final targetId = _continueWatchingIdentityTargetId(item); + final client = serverId == null ? null : _serverManager.getClient(serverId); + + if (client != null && targetId != null && targetId.isNotEmpty) { + try { + final cacheKey = buildGlobalKey(serverId!, targetId); + final externalIds = await externalIdLoads.putIfAbsent(cacheKey, () => client.fetchExternalIds(targetId)); + _addExternalIdentityKeys(keys, scope, externalIds); + } catch (e, stackTrace) { + appLogger.d( + 'Failed to resolve Continue Watching identity for ${item.globalKey}', + error: e, + stackTrace: stackTrace, + ); + } + } + + final stableGuid = _stableMediaGuid(item.guid); + if (stableGuid != null) { + final guidScope = item.kind == MediaKind.episode ? 'episode' : scope; + keys.add('$guidScope:guid:$stableGuid'); + } + + return keys; + } + + String? _continueWatchingIdentityScope(MediaItem item) { + return switch (item.kind) { + MediaKind.episode || MediaKind.season || MediaKind.show => 'show', + MediaKind.movie => 'movie', + _ => null, + }; + } + + String? _continueWatchingIdentityTargetId(MediaItem item) { + return switch (item.kind) { + MediaKind.episode => item.grandparentId, + MediaKind.season => item.grandparentId ?? item.parentId, + MediaKind.show || MediaKind.movie => item.id, + _ => null, + }; + } + + void _addExternalIdentityKeys(Set keys, String scope, ExternalIds externalIds) { + final imdb = externalIds.imdb?.trim().toLowerCase(); + if (imdb != null && imdb.isNotEmpty) keys.add('$scope:imdb:$imdb'); + final tmdb = externalIds.tmdb; + if (tmdb != null) keys.add('$scope:tmdb:$tmdb'); + final tvdb = externalIds.tvdb; + if (tvdb != null) keys.add('$scope:tvdb:$tvdb'); + } + + String? _stableMediaGuid(String? guid) { + final value = guid?.trim(); + if (value == null || value.isEmpty) return null; + if (!value.contains('://')) return null; + if (value.contains('agents.none://')) return null; + return value.toLowerCase(); + } + /// Fetch recommendation hubs from all servers as neutral [MediaHub]s. /// When useGlobalHubs is true (default), rich-hub backends use their true /// home page hubs (Plex's promoted/global hub endpoint). diff --git a/test/services/data_aggregation_bridge_test.dart b/test/services/data_aggregation_bridge_test.dart index dcee9c8d..fdfb947c 100644 --- a/test/services/data_aggregation_bridge_test.dart +++ b/test/services/data_aggregation_bridge_test.dart @@ -164,6 +164,150 @@ void main() { expect(captured.single.queryParameters['count'], '21'); }); + test('getOnDeckFromAllServers hides duplicate show entries by stable show ids', () async { + final client = PlexClient.forTesting( + config: PlexConfig( + baseUrl: 'https://plex.example.com', + token: 'token', + clientIdentifier: 'client-id', + product: 'Plezy', + version: 'test', + ), + serverId: 'plex-1', + serverName: 'Plex', + httpClient: MockClient((req) async { + if (req.url.path == '/hubs') { + return _json({ + 'MediaContainer': { + 'Hub': [ + { + 'key': '/hubs/home/continueWatching', + 'title': 'Continue Watching', + 'type': 'mixed', + 'hubIdentifier': 'home.continue', + 'size': 2, + 'Metadata': [ + { + 'ratingKey': 'old-episode', + 'type': 'episode', + 'title': 'Episode 1', + 'grandparentRatingKey': 'old-show', + 'grandparentTitle': 'Shared Show', + 'guid': 'plex://episode/shared-episode-1', + 'lastViewedAt': 100, + 'librarySectionID': 1, + }, + { + 'ratingKey': 'new-episode', + 'type': 'episode', + 'title': 'Episode 2', + 'grandparentRatingKey': 'new-show', + 'grandparentTitle': 'Shared Show', + 'guid': 'plex://episode/shared-episode-2', + 'lastViewedAt': 200, + 'librarySectionID': 2, + }, + ], + }, + ], + }, + }); + } + if (req.url.path == '/library/metadata/old-show' || req.url.path == '/library/metadata/new-show') { + return _json({ + 'MediaContainer': { + 'Metadata': [ + { + 'ratingKey': req.url.pathSegments.last, + 'type': 'show', + 'title': 'Shared Show', + 'Guid': [ + {'id': 'tvdb://12345'}, + ], + }, + ], + }, + }); + } + return http.Response('unexpected request', 500); + }), + ); + addTearDown(client.close); + manager.debugRegisterClientForTesting(client); + + final items = await service.getOnDeckFromAllServers(limit: 10); + + expect(items.map((item) => item.id), ['new-episode']); + }); + + test('getOnDeckFromAllServers keeps duplicate titles without stable ids', () async { + final client = PlexClient.forTesting( + config: PlexConfig( + baseUrl: 'https://plex.example.com', + token: 'token', + clientIdentifier: 'client-id', + product: 'Plezy', + version: 'test', + ), + serverId: 'plex-1', + serverName: 'Plex', + httpClient: MockClient((req) async { + if (req.url.path == '/hubs') { + return _json({ + 'MediaContainer': { + 'Hub': [ + { + 'key': '/hubs/home/continueWatching', + 'title': 'Continue Watching', + 'type': 'mixed', + 'hubIdentifier': 'home.continue', + 'size': 2, + 'Metadata': [ + { + 'ratingKey': 'old-unmatched', + 'type': 'episode', + 'title': 'Episode 1', + 'grandparentRatingKey': 'old-unmatched-show', + 'grandparentTitle': 'Shared Show', + 'guid': 'com.plexapp.agents.none://old-unmatched', + 'lastViewedAt': 100, + }, + { + 'ratingKey': 'new-unmatched', + 'type': 'episode', + 'title': 'Episode 2', + 'grandparentRatingKey': 'new-unmatched-show', + 'grandparentTitle': 'Shared Show', + 'guid': 'com.plexapp.agents.none://new-unmatched', + 'lastViewedAt': 200, + }, + ], + }, + ], + }, + }); + } + if (req.url.path == '/library/metadata/old-unmatched-show' || + req.url.path == '/library/metadata/new-unmatched-show') { + return _json({ + 'MediaContainer': { + 'Metadata': [ + {'ratingKey': req.url.pathSegments.last, 'type': 'show', 'title': 'Shared Show'}, + ], + }, + }); + } + return http.Response('unexpected request', 500); + }), + ); + addTearDown(client.close); + manager.debugRegisterClientForTesting(client); + + final items = await service.getOnDeckFromAllServers(limit: 10); + + expect(items.map((item) => item.id), ['new-unmatched', 'old-unmatched']); + }); + test('per-library hubs skip playback rows and fetch in bounded batches', () async { final captured = []; var activeLatest = 0;