fix(plex): restore library filters and sorts

close #1020
This commit is contained in:
edde746
2026-05-12 05:36:48 +02:00
parent ae7f4a7871
commit e5922f4870
3 changed files with 74 additions and 98 deletions
@@ -541,6 +541,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
resetPaginationState();
_filters = [];
_sortOptions = [];
_jellyfinFilterValues = const {};
_jellyfinAlphaPrefix = null;
_selectedFilters = {};
_selectedSort = null;
_isSortDescending = false;
+4 -56
View File
@@ -75,13 +75,6 @@ class _LibraryContentResult {
const _LibraryContentResult({required this.items, required this.totalSize});
}
class _LibrarySectionDetails {
final List<MediaFilter> filters;
final List<MediaSort> sorts;
const _LibrarySectionDetails({required this.filters, required this.sorts});
}
/// Process hub response in an isolate.
/// Top-level function so it can be passed to [Isolate.run].
List<PlexHubDto> _processHubResponse(
@@ -195,8 +188,6 @@ class PlexClient with MediaServerCacheMixin, _PlexLiveTvClientMethods implements
/// Server-level preferences fetched from /:/prefs
Map<String, dynamic> _serverPrefs = {};
final Map<String, Future<_LibrarySectionDetails>> _librarySectionDetails = {};
/// Get all fetched server preferences
Map<String, dynamic> get serverPrefs => Map.unmodifiable(_serverPrefs);
@@ -1461,52 +1452,8 @@ class PlexClient with MediaServerCacheMixin, _PlexLiveTvClientMethods implements
/// Get available filters for a library section
Future<List<MediaFilter>> getLibraryFilters(String sectionId) async {
if (sectionId == 'shared') return [];
final details = await _getLibrarySectionDetails(sectionId);
return details.filters;
}
Future<_LibrarySectionDetails> _getLibrarySectionDetails(String sectionId) {
return _librarySectionDetails.putIfAbsent(sectionId, () async {
try {
final response = await _getWithFailover('/library/sections/$sectionId', queryParameters: {'includeDetails': 1});
return _extractLibrarySectionDetails(response);
} catch (_) {
_librarySectionDetails.remove(sectionId)?.ignore();
rethrow;
}
});
}
_LibrarySectionDetails _extractLibrarySectionDetails(MediaServerResponse response) {
final container = _getMediaContainer(response);
if (container == null) return const _LibrarySectionDetails(filters: [], sorts: []);
return _LibrarySectionDetails(
filters: _extractFirstNestedList(container, 'Filter', MediaFilter.fromJson),
sorts: _extractFirstNestedList(container, 'Sort', MediaSort.fromJson),
);
}
List<T> _extractFirstNestedList<T>(
Map<String, dynamic> container,
String key,
T Function(Map<String, dynamic>) fromJson,
) {
final direct = _parseRawList(container[key], fromJson);
if (direct.isNotEmpty) return direct;
final directories = container['Directory'];
if (directories is! List) return [];
for (final directory in directories) {
if (directory is! Map) continue;
final parsed = _parseRawList(directory[key], fromJson);
if (parsed.isNotEmpty) return parsed;
}
return [];
}
List<T> _parseRawList<T>(Object? raw, T Function(Map<String, dynamic>) fromJson) {
if (raw is! List) return [];
return raw.whereType<Map>().map((json) => fromJson(Map<String, dynamic>.from(json))).toList();
final response = await _getWithFailover('/library/sections/$sectionId/filters');
return _extractDirectoryList(response, MediaFilter.fromJson);
}
/// Get first characters (alphabet index) for a library section
@@ -1563,7 +1510,8 @@ class PlexClient with MediaServerCacheMixin, _PlexLiveTvClientMethods implements
];
}
try {
final sorts = (await _getLibrarySectionDetails(sectionId)).sorts;
final response = await _getWithFailover('/library/sections/$sectionId/sorts');
final sorts = _extractDirectoryList(response, MediaSort.fromJson);
if (sorts.isNotEmpty) {
return sorts;
+68 -42
View File
@@ -5,8 +5,6 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:plezy/database/app_database.dart';
import 'package:plezy/media/media_filter.dart';
import 'package:plezy/media/media_sort.dart';
import 'package:plezy/models/plex/plex_config.dart';
import 'package:plezy/services/plex_api_cache.dart';
import 'package:plezy/services/plex_client.dart';
@@ -37,58 +35,86 @@ void main() {
);
}
test('filters and sorts share the includeDetails section request', () async {
var requestCount = 0;
test('filters and sorts use dedicated Plex endpoints', () async {
final requests = <Uri>[];
final client = makeClient((request) async {
requestCount++;
expect(request.url.path, '/library/sections/1');
expect(request.url.queryParameters['includeDetails'], '1');
return http.Response(jsonEncode(_sectionDetailsPayload()), 200, headers: {'content-type': 'application/json'});
requests.add(request.url);
return switch (request.url.path) {
'/library/sections/1/filters' => http.Response(
jsonEncode(_filtersPayload()),
200,
headers: {'content-type': 'application/json'},
),
'/library/sections/1/sorts' => http.Response(
jsonEncode(_sortsPayload()),
200,
headers: {'content-type': 'application/json'},
),
_ => http.Response('not found', 404),
};
});
addTearDown(client.close);
final results = await Future.wait<Object>([
client.getLibraryFilters('1'),
client.fetchSortOptions('1', libraryType: 'movie'),
]);
final filters = await client.getLibraryFilters('1');
final sorts = await client.fetchSortOptions('1', libraryType: 'show');
final filters = results[0] as List<MediaFilter>;
final sorts = results[1] as List<MediaSort>;
expect(requestCount, 1);
expect(filters.map((f) => f.filter), ['genre', 'year']);
expect(sorts.map((s) => s.key), ['addedAt', 'titleSort']);
expect(requests.map((u) => u.path), ['/library/sections/1/filters', '/library/sections/1/sorts']);
expect(requests.every((u) => u.queryParameters.isEmpty), isTrue);
expect(filters.map((f) => f.filter), ['genre', 'year', 'unwatched']);
expect(sorts.map((s) => s.key), [
'titleSort',
'rating',
'audienceRating',
'addedAt',
'episode.addedAt',
'lastViewedAt',
'random',
]);
});
}
Map<String, dynamic> _sectionDetailsPayload() => {
Map<String, dynamic> _filtersPayload() => {
'MediaContainer': {
'Directory': [
{'key': 'all', 'title': 'All Movies'},
{
'key': '/library/sections/1/all?type=1',
'title': 'Movies',
'type': '1',
'Filter': [
{
'filter': 'genre',
'filterType': 'string',
'key': '/library/sections/1/genre',
'title': 'Genre',
'type': 'filter',
},
{
'filter': 'year',
'filterType': 'integer',
'key': '/library/sections/1/year',
'title': 'Year',
'type': 'filter',
},
],
'Sort': [
{'defaultDirection': 'desc', 'descKey': 'addedAt:desc', 'key': 'addedAt', 'title': 'Date Added'},
{'defaultDirection': 'asc', 'descKey': 'titleSort:desc', 'key': 'titleSort', 'title': 'Name'},
],
'filter': 'genre',
'filterType': 'string',
'key': '/library/sections/1/genre',
'title': 'Genre',
'type': 'filter',
},
{'filter': 'year', 'filterType': 'integer', 'key': '/library/sections/1/year', 'title': 'Year', 'type': 'filter'},
{
'filter': 'unwatched',
'filterType': 'boolean',
'key': '/library/sections/1/unwatched',
'title': 'Unwatched',
'type': 'filter',
},
],
},
};
Map<String, dynamic> _sortsPayload() => {
'MediaContainer': {
'Directory': [
{'defaultDirection': 'asc', 'descKey': 'titleSort:desc', 'key': 'titleSort', 'title': 'Title'},
{'defaultDirection': 'desc', 'descKey': 'rating:desc', 'key': 'rating', 'title': 'Critic Rating'},
{
'defaultDirection': 'desc',
'descKey': 'audienceRating:desc',
'key': 'audienceRating',
'title': 'Audience Rating',
},
{'defaultDirection': 'desc', 'descKey': 'addedAt:desc', 'key': 'addedAt', 'title': 'Date Added'},
{
'defaultDirection': 'desc',
'descKey': 'episode.addedAt:desc',
'key': 'episode.addedAt',
'title': 'Last Episode Date Added',
},
{'defaultDirection': 'desc', 'descKey': 'lastViewedAt:desc', 'key': 'lastViewedAt', 'title': 'Date Viewed'},
{'defaultDirection': 'desc', 'descKey': 'random:desc', 'key': 'random', 'title': 'Randomly'},
],
},
};