Consolidates duplicated logic behind shared implementations — paginated grid tabs, focus chrome, cached remote stores, sheet selection columns, the server artifact store and a test fixture layer — and removes code that had become unreachable. Net reduction of about 5,500 lines with no behaviour change. Where a fix had landed separately in code that moved into a shared helper, the fix was re-applied inside the helper rather than left behind in the copy that went away.
122 lines
4.7 KiB
Dart
122 lines
4.7 KiB
Dart
// ignore_for_file: invalid_annotation_target
|
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
|
|
|
import '../utils/media_server_http_client.dart' show AbortController;
|
|
import 'media_kind.dart';
|
|
|
|
part 'library_query.freezed.dart';
|
|
|
|
/// Sort order applied to a library query.
|
|
enum LibrarySortDirection { ascending, descending }
|
|
|
|
@freezed
|
|
sealed class LibrarySort with _$LibrarySort {
|
|
/// Backend-neutral sort field. Common values: `addedAt`, `originallyAvailableAt`,
|
|
/// `lastViewedAt`, `title`, `rating`, `viewCount`, `random`.
|
|
const factory LibrarySort({
|
|
required String field,
|
|
@Default(LibrarySortDirection.descending) LibrarySortDirection direction,
|
|
}) = _LibrarySort;
|
|
}
|
|
|
|
/// A single filter clause. The semantics of `field` and `value` are
|
|
/// backend-translated — the neutral query just carries the intent.
|
|
@freezed
|
|
sealed class LibraryFilter with _$LibraryFilter {
|
|
const factory LibraryFilter({required String field, @Default('=') String op, required List<String> values}) =
|
|
_LibraryFilter;
|
|
}
|
|
|
|
/// Backend-neutral library content query. Each backend's adapter translates
|
|
/// these into its own query DSL (Plex `/library/sections/{id}/all?type=...`
|
|
/// or Jellyfin `/Items?ParentId=...&Filters=...`).
|
|
@freezed
|
|
sealed class LibraryQuery with _$LibraryQuery {
|
|
const factory LibraryQuery({
|
|
/// Restrict to a single kind (e.g. `MediaKind.movie`). Null = library default.
|
|
MediaKind? kind,
|
|
|
|
/// Restrict to multiple kinds when no single [kind] represents the browse
|
|
/// surface. When non-empty, translators prefer this over [kind].
|
|
@Default(<MediaKind>[]) List<MediaKind> includeKinds,
|
|
|
|
/// Pagination — zero-based offset.
|
|
@Default(0) int offset,
|
|
@Default(50) int limit,
|
|
|
|
LibrarySort? sort,
|
|
@Default(<LibraryFilter>[]) List<LibraryFilter> filters,
|
|
|
|
/// Free-text search restricted to this library. Distinct from the global
|
|
/// search endpoint.
|
|
String? search,
|
|
|
|
/// Whether to include items the active user has already watched.
|
|
@Default(true) bool includeWatched,
|
|
|
|
/// Restrict to items the user marked favorite (Jellyfin `Filters=IsFavorite`).
|
|
/// Plex has no equivalent; its translator ignores the flag.
|
|
@Default(false) bool favoritesOnly,
|
|
|
|
/// Restrict the result to items whose sort name starts with this string —
|
|
/// the alpha-jump bar's filter UX. The literal `#` is a sentinel for
|
|
/// "non-alphabetic" and translates to a `NameLessThan=A` query for backends
|
|
/// that support it.
|
|
String? nameStartsWith,
|
|
|
|
/// Genre filter — used by the per-library filter sheet. Backends that
|
|
/// take multiple values (Jellyfin) AND/intersect; those that take one
|
|
/// (Plex's existing flow) consult `filters` instead.
|
|
List<String>? genres,
|
|
List<String>? officialRatings,
|
|
List<int>? years,
|
|
List<String>? tags,
|
|
}) = _LibraryQuery;
|
|
}
|
|
|
|
/// Page of items returned by [MediaServerClient.getLibraryContent].
|
|
/// Carries the total count so the UI can render correct pagination affordances.
|
|
@freezed
|
|
sealed class LibraryPage<T> with _$LibraryPage<T> {
|
|
const factory LibraryPage({required List<T> items, required int totalCount, @Default(0) int offset}) =
|
|
_LibraryPage<T>;
|
|
}
|
|
|
|
/// Conservative total for a page whose backend omitted an exact count. A full
|
|
/// page adds one sentinel item so callers keep pagination enabled without
|
|
/// claiming to know the real total.
|
|
int fallbackPageTotal({required int offset, required int itemCount, int? requestedSize}) {
|
|
final fullPage = requestedSize != null && requestedSize > 0 && itemCount >= requestedSize;
|
|
return offset + itemCount + (fullPage ? 1 : 0);
|
|
}
|
|
|
|
/// Walk every page of a paginated endpoint and concatenate the results.
|
|
///
|
|
/// [fetchPage] receives a zero-based offset and [pageSize] and is called until
|
|
/// a page comes back empty, the accumulated count reaches the page's
|
|
/// [LibraryPage.totalCount], or — when [stopOnShortPage] is set — a page comes
|
|
/// back shorter than [pageSize]. The short-page break is for backends whose
|
|
/// total is unreliable; leave it off when the total is authoritative.
|
|
///
|
|
/// [abort] is checked before and after every request. Errors propagate.
|
|
Future<List<T>> drainPages<T>(
|
|
Future<LibraryPage<T>> Function(int start, int size) fetchPage, {
|
|
required int pageSize,
|
|
AbortController? abort,
|
|
bool stopOnShortPage = false,
|
|
}) async {
|
|
final all = <T>[];
|
|
var start = 0;
|
|
while (true) {
|
|
abort?.throwIfAborted();
|
|
final page = await fetchPage(start, pageSize);
|
|
abort?.throwIfAborted();
|
|
if (page.items.isEmpty) break;
|
|
all.addAll(page.items);
|
|
start += page.items.length;
|
|
if (start >= page.totalCount) break;
|
|
if (stopOnShortPage && page.items.length < pageSize) break;
|
|
}
|
|
return all;
|
|
}
|