fix(hubs): keep recently-added detail scoped to its library
close #1282
This commit is contained in:
@@ -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§ionID=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');
|
||||||
|
|
||||||
|
|||||||
@@ -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§ionID=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.
|
||||||
|
|||||||
@@ -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§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<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§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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user