fix(discover): hide duplicate continue watching

close #1113
This commit is contained in:
edde746
2026-05-26 14:48:16 +02:00
parent ce19b9dfa7
commit be86a33057
2 changed files with 281 additions and 0 deletions
+137
View File
@@ -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<List<MediaItem>> _deduplicateContinueWatching(List<MediaItem> items) async {
if (items.length < 2) return items;
final bucketCounts = <String, int>{};
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 = <String, Future<ExternalIds>>{};
final identityKeysByIndex = <int, Set<String>>{};
final identityKeyLoads = <Future<void>>[];
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 = <String>{};
final result = <MediaItem>[];
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 <String>{};
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<Set<String>> _continueWatchingIdentityKeys(
MediaItem item,
Map<String, Future<ExternalIds>> externalIdLoads,
) async {
final scope = _continueWatchingIdentityScope(item);
if (scope == null) return const {};
final keys = <String>{};
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<String> 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).
@@ -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 = <Uri>[];
var activeLatest = 0;