@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user