diff --git a/lib/models/plex_metadata.dart b/lib/models/plex_metadata.dart index a6801b6c..25e45c20 100644 --- a/lib/models/plex_metadata.dart +++ b/lib/models/plex_metadata.dart @@ -92,6 +92,7 @@ class PlexMetadata with MultiServerFields { final int? playlistItemID; // Playlist item ID (for dumb playlists only) final int? playQueueItemID; // Play queue item ID (unique even for duplicates) final int? librarySectionID; // Library section ID this item belongs to + final String? librarySectionTitle; // Library section title this item belongs to final String? ratingImage; // Rating source URI (e.g. rottentomatoes://image.rating.ripe) final String? audienceRatingImage; // Audience rating source URI final String? tagline; @@ -174,6 +175,7 @@ class PlexMetadata with MultiServerFields { this.playlistItemID, this.playQueueItemID, this.librarySectionID, + this.librarySectionTitle, this.ratingImage, this.audienceRatingImage, this.tagline, @@ -229,6 +231,7 @@ class PlexMetadata with MultiServerFields { int? playlistItemID, int? playQueueItemID, int? librarySectionID, + String? librarySectionTitle, String? ratingImage, String? audienceRatingImage, String? tagline, @@ -282,6 +285,7 @@ class PlexMetadata with MultiServerFields { playlistItemID: playlistItemID ?? this.playlistItemID, playQueueItemID: playQueueItemID ?? this.playQueueItemID, librarySectionID: librarySectionID ?? this.librarySectionID, + librarySectionTitle: librarySectionTitle ?? this.librarySectionTitle, ratingImage: ratingImage ?? this.ratingImage, audienceRatingImage: audienceRatingImage ?? this.audienceRatingImage, tagline: tagline ?? this.tagline, diff --git a/lib/models/plex_metadata.g.dart b/lib/models/plex_metadata.g.dart index cd57d744..eb2a598e 100644 --- a/lib/models/plex_metadata.g.dart +++ b/lib/models/plex_metadata.g.dart @@ -48,6 +48,7 @@ PlexMetadata _$PlexMetadataFromJson(Map json) => PlexMetadata( playlistItemID: (json['playlistItemID'] as num?)?.toInt(), playQueueItemID: (json['playQueueItemID'] as num?)?.toInt(), librarySectionID: (json['librarySectionID'] as num?)?.toInt(), + librarySectionTitle: json['librarySectionTitle'] as String?, ratingImage: json['ratingImage'] as String?, audienceRatingImage: json['audienceRatingImage'] as String?, tagline: json['tagline'] as String?, @@ -100,6 +101,7 @@ Map _$PlexMetadataToJson(PlexMetadata instance) => clients, { int? limit, Set? hiddenLibraryKeys, + Map>? librariesByServer, }) async { appLogger.d('Fetching global hubs from ${clients.length} servers'); @@ -150,7 +156,9 @@ class DataAggregationService { }); final results = await Future.wait(hubFutures); - final result = _collectAndLimitResults(results, limit); + // Split "Recently Added" hubs that combine items from multiple libraries + final splitResults = results.map((hubs) => _splitRecentlyAddedHubs(hubs, librariesByServer)).toList(); + final result = _collectAndLimitResults(splitResults, limit); appLogger.i('Fetched ${result.length} global hubs from all servers'); @@ -300,6 +308,103 @@ class DataAggregationService { return limit != null && limit < all.length ? all.sublist(0, limit) : all; } + /// Split "Recently Added" hubs that contain items from multiple libraries + /// into separate per-library hubs, matching the official Plex client behavior. + List _splitRecentlyAddedHubs( + List hubs, + Map>? librariesByServer, + ) { + final result = []; + + for (final hub in hubs) { + final hubId = hub.hubIdentifier?.toLowerCase() ?? ''; + if (!hubId.contains('.recent')) { + result.add(hub); + continue; + } + + // Group items by librarySectionID + final groups = >{}; + final ungrouped = []; + + for (final item in hub.items) { + final sectionId = item.librarySectionID; + if (sectionId == null) { + ungrouped.add(item); + } else { + groups.putIfAbsent(sectionId, () => []).add(item); + } + } + + // Single library (or no groupable items) — keep hub unchanged + if (groups.length <= 1) { + result.add(hub); + continue; + } + + // Multiple libraries — create one hub per library + for (final entry in groups.entries) { + final items = entry.value; + final libraryName = _resolveLibraryName(items.first, librariesByServer); + final title = libraryName != null ? 'Recently Added in $libraryName' : hub.title; + + result.add(PlexHub( + hubKey: hub.hubKey, + title: title, + type: hub.type, + hubIdentifier: '${hub.hubIdentifier}_${entry.key}', + size: items.length, + more: hub.more, + items: items, + serverId: hub.serverId, + serverName: hub.serverName, + )); + } + + // Keep ungrouped items in a hub with the original title + if (ungrouped.isNotEmpty) { + result.add(PlexHub( + hubKey: hub.hubKey, + title: hub.title, + type: hub.type, + hubIdentifier: hub.hubIdentifier, + size: ungrouped.length, + more: hub.more, + items: ungrouped, + serverId: hub.serverId, + serverName: hub.serverName, + )); + } + } + + return result; + } + + /// Resolve library name from item metadata or library lookup map. + String? _resolveLibraryName( + PlexMetadata item, + Map>? librariesByServer, + ) { + // Try librarySectionTitle from the item itself (Plex API often includes it) + if (item.librarySectionTitle != null && item.librarySectionTitle!.isNotEmpty) { + return item.librarySectionTitle; + } + + // Fall back to library lookup + if (librariesByServer != null && item.serverId != null && item.librarySectionID != null) { + final serverLibraries = librariesByServer[item.serverId]; + if (serverLibraries != null) { + for (final lib in serverLibraries) { + if (lib.key == item.librarySectionID.toString()) { + return lib.title; + } + } + } + } + + return null; + } + /// Base helper for per-server fan-out operations /// /// Returns raw results as (serverId, result) tuples.