// 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 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([]) List includeKinds, /// Pagination — zero-based offset. @Default(0) int offset, @Default(50) int limit, LibrarySort? sort, @Default([]) List 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? genres, List? officialRatings, List? years, List? 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 with _$LibraryPage { const factory LibraryPage({required List items, required int totalCount, @Default(0) int offset}) = _LibraryPage; } /// 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> drainPages( Future> Function(int start, int size) fetchPage, { required int pageSize, AbortController? abort, bool stopOnShortPage = false, }) async { final all = []; 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; }