From 7de78023682eb790dc0311d9cfc86378121e9457 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Mon, 8 Jun 2026 22:03:28 +0200 Subject: [PATCH] fix(hubs): keep recently-added detail scoped to its library close #1282 --- lib/screens/hub_detail_screen.dart | 11 +++-- lib/utils/media_server_http_client.dart | 9 ++-- lib/utils/plex_library_section_utils.dart | 11 ++++- ...edia_server_http_client_builduri_test.dart | 45 +++++++++++++++++++ .../plex_library_section_utils_test.dart | 32 +++++++++++++ 5 files changed, 97 insertions(+), 11 deletions(-) create mode 100644 test/utils/media_server_http_client_builduri_test.dart create mode 100644 test/utils/plex_library_section_utils_test.dart diff --git a/lib/screens/hub_detail_screen.dart b/lib/screens/hub_detail_screen.dart index 0be7e583..a3d18947 100644 --- a/lib/screens/hub_detail_screen.dart +++ b/lib/screens/hub_detail_screen.dart @@ -14,6 +14,7 @@ import '../widgets/settings_builder.dart'; import '../utils/app_logger.dart'; import '../utils/grid_size_calculator.dart'; import '../utils/platform_detector.dart'; +import '../utils/plex_library_section_utils.dart'; import '../utils/provider_extensions.dart'; import '../widgets/focusable_media_card.dart'; import '../widgets/ios_status_bar_tap_scroll_to_top.dart'; @@ -130,20 +131,18 @@ class _HubDetailScreenState extends State // Hub ids can have various formats: // - /hubs/sections/1/... (Plex) // - /library/sections/1/all?... (Plex) + // - /hubs/home/recentlyAdded?type=2§ionID=1 (Plex home hubs — id in query) // - home.recent / library..continue (Jellyfin synthesized) final hubKey = widget.hub.id; appLogger.d('Hub key: $hubKey'); - RegExpMatch? match = RegExp(r'/hubs/sections/(\d+)').firstMatch(hubKey); - match ??= RegExp(r'/library/sections/(\d+)').firstMatch(hubKey); - match ??= RegExp(r'sections/(\d+)').firstMatch(hubKey); + final sectionId = plexLibrarySectionIdFromString(hubKey); - if (match != null) { - final sectionId = match.group(1)!; + if (sectionId != null) { appLogger.d('Loading sorts for section: $sectionId'); final client = context.tryGetMediaClientForServer(ServerId(serverId)); - final sorts = client == null ? const [] : await client.fetchSortOptions(sectionId); + final sorts = client == null ? const [] : await client.fetchSortOptions('$sectionId'); appLogger.d('Loaded ${sorts.length} sorts'); diff --git a/lib/utils/media_server_http_client.dart b/lib/utils/media_server_http_client.dart index 5fb9fb01..7a9c6d84 100644 --- a/lib/utils/media_server_http_client.dart +++ b/lib/utils/media_server_http_client.dart @@ -376,9 +376,12 @@ class MediaServerHttpClient { Uri _buildUri(String path, Map? queryParameters) { final base = baseUrl.endsWith('/') ? baseUrl : '$baseUrl/'; final cleanPath = path.startsWith('/') ? path.substring(1) : path; - final query = MediaServerHttpClient.encodeQueryParameters(queryParameters); - final full = query.isEmpty ? '$base$cleanPath' : '$base$cleanPath?$query'; - return Uri.parse(full); + // [path] may already carry a query string (e.g. Plex home hub keys like + // `/hubs/home/recentlyAdded?type=2§ionID=2`). Merge via [_appendQuery] — + // the same path used for absolute URLs in [_send] — so extra params join with + // `&` instead of producing a malformed double-`?` URL that corrupts the + // existing params (e.g. sectionID). + return _appendQuery(Uri.parse('$base$cleanPath'), queryParameters); } /// Append query parameters to an already-parsed URI. diff --git a/lib/utils/plex_library_section_utils.dart b/lib/utils/plex_library_section_utils.dart index 448b9300..b015b692 100644 --- a/lib/utils/plex_library_section_utils.dart +++ b/lib/utils/plex_library_section_utils.dart @@ -2,6 +2,11 @@ import 'json_utils.dart'; final RegExp plexLibrarySectionPathPattern = RegExp(r'/(?:library|hubs)/sections/(\d+)'); +/// Matches a section id carried in a query string rather than the path, e.g. +/// `/hubs/home/recentlyAdded?type=2§ionID=2`. The `[?&]` anchor keeps it from +/// false-matching a path segment; the alternation also accepts `librarySectionID=`. +final RegExp plexLibrarySectionQueryPattern = RegExp(r'[?&](?:librarySectionID|sectionID)=(\d+)'); + int? plexLibrarySectionIdFromJson(Map? json) { if (json == null) return null; final direct = flexibleInt(json['librarySectionID']) ?? flexibleInt(json['targetLibrarySectionID']); @@ -18,8 +23,10 @@ int? plexLibrarySectionIdFromString(String? value) { if (value == null || value == 'shared') return null; final direct = int.tryParse(value); if (direct != null) return direct; - final match = plexLibrarySectionPathPattern.firstMatch(value); - return match == null ? null : int.tryParse(match.group(1)!); + final pathMatch = plexLibrarySectionPathPattern.firstMatch(value); + if (pathMatch != null) return int.tryParse(pathMatch.group(1)!); + final queryMatch = plexLibrarySectionQueryPattern.firstMatch(value); + return queryMatch == null ? null : int.tryParse(queryMatch.group(1)!); } String? plexLibrarySectionTitleFromJson(Map? json) { diff --git a/test/utils/media_server_http_client_builduri_test.dart b/test/utils/media_server_http_client_builduri_test.dart new file mode 100644 index 00000000..e08d343b --- /dev/null +++ b/test/utils/media_server_http_client_builduri_test.dart @@ -0,0 +1,45 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/utils/media_server_http_client.dart'; + +void main() { + group('MediaServerHttpClient.buildUri', () { + test('merges params into a path that already has a query string', () { + // Regression for #1282: Plex home hub keys carry the library in the query + // string (`sectionID`). Pagination params must merge with `&`, not append a + // second `?` that corrupts `sectionID`. + final client = MediaServerHttpClient(baseUrl: 'https://plex.example.com'); + final uri = client.buildUri( + '/hubs/home/recentlyAdded?type=2§ionID=2', + queryParameters: {'X-Plex-Container-Start': 0, 'X-Plex-Container-Size': 200}, + ); + + expect(uri.queryParameters['type'], '2'); + expect(uri.queryParameters['sectionID'], '2'); // not "2?X-Plex-Container-Start=0" + expect(uri.queryParameters['X-Plex-Container-Start'], '0'); + expect(uri.queryParameters['X-Plex-Container-Size'], '200'); + expect('${uri.scheme}://${uri.host}${uri.path}', 'https://plex.example.com/hubs/home/recentlyAdded'); + + client.close(); + }); + + test('keeps a single ? for a plain path (no regression)', () { + final client = MediaServerHttpClient(baseUrl: 'https://plex.example.com'); + final uri = client.buildUri('/library/sections/2/all', queryParameters: {'sort': 'addedAt:desc'}); + + expect(uri.path, '/library/sections/2/all'); + expect(uri.queryParameters['sort'], 'addedAt:desc'); + + client.close(); + }); + + test('leaves a query-bearing path untouched when no extra params are given', () { + final client = MediaServerHttpClient(baseUrl: 'https://plex.example.com'); + final uri = client.buildUri('/library/sections/2/all?genre=5'); + + expect(uri.path, '/library/sections/2/all'); + expect(uri.queryParameters['genre'], '5'); + + client.close(); + }); + }); +} diff --git a/test/utils/plex_library_section_utils_test.dart b/test/utils/plex_library_section_utils_test.dart new file mode 100644 index 00000000..bda7c754 --- /dev/null +++ b/test/utils/plex_library_section_utils_test.dart @@ -0,0 +1,32 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/utils/plex_library_section_utils.dart'; + +void main() { + group('plexLibrarySectionIdFromString', () { + test('parses sectionID from a recentlyAdded home-hub query string', () { + // #1282: the section is carried in the query, not the path. + expect(plexLibrarySectionIdFromString('/hubs/home/recentlyAdded?type=2§ionID=2'), 2); + }); + + test('path section still wins over query params', () { + expect(plexLibrarySectionIdFromString('/library/sections/3/all?genre=5'), 3); + }); + + test('accepts a plain numeric id', () { + expect(plexLibrarySectionIdFromString('7'), 7); + }); + + test('accepts librarySectionID in a query string', () { + expect(plexLibrarySectionIdFromString('/hubs/sections/all?librarySectionID=9'), 9); + }); + + test('returns null for shared / null', () { + expect(plexLibrarySectionIdFromString('shared'), isNull); + expect(plexLibrarySectionIdFromString(null), isNull); + }); + + test('returns null when no section is present anywhere', () { + expect(plexLibrarySectionIdFromString('/hubs/home/recentlyAdded?type=1'), isNull); + }); + }); +}