fix(hubs): keep recently-added detail scoped to its library

close #1282
This commit is contained in:
edde746
2026-06-08 22:03:28 +02:00
parent d44a8994ce
commit 7de7802368
5 changed files with 97 additions and 11 deletions
+5 -6
View File
@@ -14,6 +14,7 @@ import '../widgets/settings_builder.dart';
import '../utils/app_logger.dart'; import '../utils/app_logger.dart';
import '../utils/grid_size_calculator.dart'; import '../utils/grid_size_calculator.dart';
import '../utils/platform_detector.dart'; import '../utils/platform_detector.dart';
import '../utils/plex_library_section_utils.dart';
import '../utils/provider_extensions.dart'; import '../utils/provider_extensions.dart';
import '../widgets/focusable_media_card.dart'; import '../widgets/focusable_media_card.dart';
import '../widgets/ios_status_bar_tap_scroll_to_top.dart'; import '../widgets/ios_status_bar_tap_scroll_to_top.dart';
@@ -130,20 +131,18 @@ class _HubDetailScreenState extends State<HubDetailScreen>
// Hub ids can have various formats: // Hub ids can have various formats:
// - /hubs/sections/1/... (Plex) // - /hubs/sections/1/... (Plex)
// - /library/sections/1/all?... (Plex) // - /library/sections/1/all?... (Plex)
// - /hubs/home/recentlyAdded?type=2&sectionID=1 (Plex home hubs — id in query)
// - home.recent / library.<id>.continue (Jellyfin synthesized) // - home.recent / library.<id>.continue (Jellyfin synthesized)
final hubKey = widget.hub.id; final hubKey = widget.hub.id;
appLogger.d('Hub key: $hubKey'); appLogger.d('Hub key: $hubKey');
RegExpMatch? match = RegExp(r'/hubs/sections/(\d+)').firstMatch(hubKey); final sectionId = plexLibrarySectionIdFromString(hubKey);
match ??= RegExp(r'/library/sections/(\d+)').firstMatch(hubKey);
match ??= RegExp(r'sections/(\d+)').firstMatch(hubKey);
if (match != null) { if (sectionId != null) {
final sectionId = match.group(1)!;
appLogger.d('Loading sorts for section: $sectionId'); appLogger.d('Loading sorts for section: $sectionId');
final client = context.tryGetMediaClientForServer(ServerId(serverId)); final client = context.tryGetMediaClientForServer(ServerId(serverId));
final sorts = client == null ? const <MediaSort>[] : await client.fetchSortOptions(sectionId); final sorts = client == null ? const <MediaSort>[] : await client.fetchSortOptions('$sectionId');
appLogger.d('Loaded ${sorts.length} sorts'); appLogger.d('Loaded ${sorts.length} sorts');
+6 -3
View File
@@ -376,9 +376,12 @@ class MediaServerHttpClient {
Uri _buildUri(String path, Map<String, dynamic>? queryParameters) { Uri _buildUri(String path, Map<String, dynamic>? queryParameters) {
final base = baseUrl.endsWith('/') ? baseUrl : '$baseUrl/'; final base = baseUrl.endsWith('/') ? baseUrl : '$baseUrl/';
final cleanPath = path.startsWith('/') ? path.substring(1) : path; final cleanPath = path.startsWith('/') ? path.substring(1) : path;
final query = MediaServerHttpClient.encodeQueryParameters(queryParameters); // [path] may already carry a query string (e.g. Plex home hub keys like
final full = query.isEmpty ? '$base$cleanPath' : '$base$cleanPath?$query'; // `/hubs/home/recentlyAdded?type=2&sectionID=2`). Merge via [_appendQuery] —
return Uri.parse(full); // 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. /// Append query parameters to an already-parsed URI.
+9 -2
View File
@@ -2,6 +2,11 @@ import 'json_utils.dart';
final RegExp plexLibrarySectionPathPattern = RegExp(r'/(?:library|hubs)/sections/(\d+)'); 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&sectionID=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<String, dynamic>? json) { int? plexLibrarySectionIdFromJson(Map<String, dynamic>? json) {
if (json == null) return null; if (json == null) return null;
final direct = flexibleInt(json['librarySectionID']) ?? flexibleInt(json['targetLibrarySectionID']); final direct = flexibleInt(json['librarySectionID']) ?? flexibleInt(json['targetLibrarySectionID']);
@@ -18,8 +23,10 @@ int? plexLibrarySectionIdFromString(String? value) {
if (value == null || value == 'shared') return null; if (value == null || value == 'shared') return null;
final direct = int.tryParse(value); final direct = int.tryParse(value);
if (direct != null) return direct; if (direct != null) return direct;
final match = plexLibrarySectionPathPattern.firstMatch(value); final pathMatch = plexLibrarySectionPathPattern.firstMatch(value);
return match == null ? null : int.tryParse(match.group(1)!); 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<String, dynamic>? json) { String? plexLibrarySectionTitleFromJson(Map<String, dynamic>? json) {
@@ -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&sectionID=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();
});
});
}
@@ -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&sectionID=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);
});
});
}