@@ -541,6 +541,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
resetPaginationState();
|
||||
_filters = [];
|
||||
_sortOptions = [];
|
||||
_jellyfinFilterValues = const {};
|
||||
_jellyfinAlphaPrefix = null;
|
||||
_selectedFilters = {};
|
||||
_selectedSort = null;
|
||||
_isSortDescending = false;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user