fix: discover pages now correctly using opds pagination (#179)
Mirror to Codeberg / Mirror to Codeberg (push) Canceled after 0s

This commit is contained in:
Daniel
2026-07-22 07:56:54 +02:00
parent aeef8274e2
commit 3ab7bbb652
8 changed files with 542 additions and 71 deletions
@@ -3,6 +3,8 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:calibre_web_companion/features/discover_details/bloc/discover_details_event.dart';
import 'package:calibre_web_companion/features/discover_details/bloc/discover_details_state.dart';
import 'package:calibre_web_companion/features/discover_details/data/models/category_feed_model.dart';
import 'package:calibre_web_companion/features/discover_details/data/models/discover_feed_model.dart';
import 'package:calibre_web_companion/features/discover_details/data/repositories/discover_details_repository.dart';
class DiscoverDetailsBloc
@@ -14,6 +16,8 @@ class DiscoverDetailsBloc
on<LoadBooks>(_onLoadBooks);
on<LoadCategories>(_onLoadCategories);
on<LoadBooksFromPath>(_onLoadBooksFromPath);
on<LoadMoreDiscoverBooks>(_onLoadMoreDiscoverBooks);
on<LoadMoreDiscoverCategories>(_onLoadMoreDiscoverCategories);
}
Future<void> _onLoadBooks(
@@ -125,4 +129,76 @@ class DiscoverDetailsBloc
);
}
}
Future<void> _onLoadMoreDiscoverBooks(
LoadMoreDiscoverBooks event,
Emitter<DiscoverDetailsState> emit,
) async {
final currentFeed = state.bookFeed;
final nextPageUrl = currentFeed?.nextPageUrl;
if (state.isLoadingMore || currentFeed == null || nextPageUrl == null) {
return;
}
emit(state.copyWith(isLoadingMore: true));
try {
final page = await repository.loadBooksFromPath(nextPageUrl);
final existingIds = currentFeed.books.map((b) => b.id).toSet();
final newBooks = page.books.where((b) => !existingIds.contains(b.id));
emit(
state.copyWith(
bookFeed: DiscoverFeedModel(
books: [...currentFeed.books, ...newBooks],
nextPageUrl: _nextOrStop(nextPageUrl, page.nextPageUrl),
),
isLoadingMore: false,
),
);
} catch (e) {
emit(state.copyWith(isLoadingMore: false));
}
}
Future<void> _onLoadMoreDiscoverCategories(
LoadMoreDiscoverCategories event,
Emitter<DiscoverDetailsState> emit,
) async {
final currentFeed = state.categoryFeed;
final nextPageUrl = currentFeed?.nextPageUrl;
if (state.isLoadingMore || currentFeed == null || nextPageUrl == null) {
return;
}
emit(state.copyWith(isLoadingMore: true));
try {
final page = await repository.loadCategoriesFromPath(nextPageUrl);
final existingIds = currentFeed.categories.map((c) => c.id).toSet();
final newCategories = page.categories.where(
(c) => !existingIds.contains(c.id),
);
emit(
state.copyWith(
categoryFeed: CategoryFeed(
categories: [...currentFeed.categories, ...newCategories],
nextPageUrl: _nextOrStop(nextPageUrl, page.nextPageUrl),
),
isLoadingMore: false,
),
);
} catch (e) {
emit(state.copyWith(isLoadingMore: false));
}
}
/// Guards against servers that keep pointing at the page we just loaded
String? _nextOrStop(String loadedUrl, String? nextUrl) =>
nextUrl == loadedUrl ? null : nextUrl;
}
@@ -40,6 +40,14 @@ class LoadBooksFromPath extends DiscoverDetailsEvent {
List<Object?> get props => [fullPath];
}
class LoadMoreDiscoverBooks extends DiscoverDetailsEvent {
const LoadMoreDiscoverBooks();
}
class LoadMoreDiscoverCategories extends DiscoverDetailsEvent {
const LoadMoreDiscoverCategories();
}
class NavigateToBook extends DiscoverDetailsEvent {
final DiscoverDetailsModel book;
@@ -16,6 +16,7 @@ class DiscoverDetailsState extends Equatable {
final BookViewModel? bookDetails;
final String? loadingBookId;
final bool isNotFound;
final bool isLoadingMore;
const DiscoverDetailsState({
this.status = DiscoverDetailsStatus.initial,
@@ -27,8 +28,12 @@ class DiscoverDetailsState extends Equatable {
this.bookDetails,
this.loadingBookId,
this.isNotFound = false,
this.isLoadingMore = false,
});
bool get hasMoreBooks => bookFeed?.nextPageUrl != null;
bool get hasMoreCategories => categoryFeed?.nextPageUrl != null;
DiscoverDetailsState copyWith({
DiscoverDetailsStatus? status,
DiscoverFeedModel? bookFeed,
@@ -39,6 +44,7 @@ class DiscoverDetailsState extends Equatable {
BookViewModel? bookDetails,
String? loadingBookId,
bool? isNotFound,
bool? isLoadingMore,
}) {
return DiscoverDetailsState(
status: status ?? this.status,
@@ -50,6 +56,7 @@ class DiscoverDetailsState extends Equatable {
bookDetails: bookDetails ?? this.bookDetails,
loadingBookId: loadingBookId ?? this.loadingBookId,
isNotFound: isNotFound ?? this.isNotFound,
isLoadingMore: isLoadingMore ?? this.isLoadingMore,
);
}
@@ -64,5 +71,6 @@ class DiscoverDetailsState extends Equatable {
bookDetails,
loadingBookId,
isNotFound,
isLoadingMore,
];
}
@@ -50,7 +50,7 @@ class DiscoverDetailsRemoteDatasource {
return DiscoverFeedModel(
books: books,
nextPageUrl: jsonData['nextPageUrl'],
nextPageUrl: _parseNextPageUrl(jsonData['feed']['link']),
);
} catch (e) {
logger.e('Error loading books: $e');
@@ -116,7 +116,7 @@ class DiscoverDetailsRemoteDatasource {
return CategoryFeed(
categories: categories,
nextPageUrl: jsonData['nextPageUrl'],
nextPageUrl: _parseNextPageUrl(jsonData['feed']['link']),
);
} catch (e) {
throw Exception('Failed to load categories: $e');
@@ -126,15 +126,7 @@ class DiscoverDetailsRemoteDatasource {
Future<DiscoverFeedModel> loadBooksFromPath(String fullPath) async {
logger.d('Loading books from path: $fullPath');
try {
String endpoint = fullPath;
final baseUrl = apiService.getBaseUrl();
if (endpoint.startsWith(baseUrl)) {
endpoint = endpoint.substring(baseUrl.length);
} else if (baseUrl.endsWith('/api/v1/opds') &&
endpoint.startsWith('/api/v1/opds')) {
endpoint = endpoint.replaceFirst('/api/v1/opds', '');
}
final endpoint = _toEndpoint(fullPath);
final jsonData = await apiService.getXmlAsJson(
endpoint: endpoint,
@@ -159,13 +151,9 @@ class DiscoverDetailsRemoteDatasource {
)
.toList();
for (final book in books) {
logger.d(book.coverUrl);
}
return DiscoverFeedModel(
books: books,
nextPageUrl: jsonData['nextPageUrl'],
nextPageUrl: _parseNextPageUrl(jsonData['feed']['link']),
);
} catch (e) {
logger.e('Error loading books from path: $e');
@@ -173,6 +161,20 @@ class DiscoverDetailsRemoteDatasource {
}
}
Future<CategoryFeed> loadCategoriesFromPath(String fullPath) async {
try {
final jsonData = await apiService.getXmlAsJson(
endpoint: _toEndpoint(fullPath),
authMethod: AuthMethod.auto,
);
return _parseCategoryFeed(jsonData);
} catch (e) {
logger.e('Error loading categories from path: $e');
throw Exception('Failed to load categories from path: $e');
}
}
Future<CategoryFeed> loadCategoriesgeneric(String path) async {
try {
final jsonData = await apiService.getXmlAsJson(
@@ -202,7 +204,38 @@ class DiscoverDetailsRemoteDatasource {
categories.sort((a, b) => a.title.compareTo(b.title));
return CategoryFeed(categories: categories);
return CategoryFeed(
categories: categories,
nextPageUrl: _parseNextPageUrl(jsonData['feed']['link']),
);
}
String _toEndpoint(String fullPath) {
String endpoint = fullPath;
final baseUrl = apiService.getBaseUrl();
if (endpoint.startsWith(baseUrl)) {
endpoint = endpoint.substring(baseUrl.length);
} else if (baseUrl.endsWith('/api/v1/opds') &&
endpoint.startsWith('/api/v1/opds')) {
endpoint = endpoint.replaceFirst('/api/v1/opds', '');
}
return endpoint;
}
String? _parseNextPageUrl(dynamic links) {
if (links == null) return null;
final linkList = links is List ? links : [links];
for (final link in linkList) {
if (link is! Map) continue;
final rel = (link['_rel'] ?? link['rel'])?.toString();
if (rel != 'next') continue;
final href = (link['_href'] ?? link['href'])?.toString();
if (href != null && href.isNotEmpty) return href;
}
return null;
}
String _getBookListPath(DiscoverType type, String? subPath) {
@@ -44,4 +44,13 @@ class DiscoverDetailsRepository {
rethrow;
}
}
Future<CategoryFeed> loadCategoriesFromPath(String fullPath) async {
try {
final categories = await dataSource.loadCategoriesFromPath(fullPath);
return categories;
} catch (e) {
rethrow;
}
}
}
@@ -162,13 +162,18 @@ class DiscoverDetailsPage extends StatelessWidget {
if (state.isShowingBooks &&
state.bookFeed != null &&
state.bookFeed!.books.isNotEmpty) {
return _buildBookGrid(context, state.bookFeed!);
return _buildBookGrid(context, state, state.bookFeed!, localizations);
}
if (state.isShowingCategories &&
state.categoryFeed != null &&
state.categoryFeed!.categories.isNotEmpty) {
return _buildCategoryList(context, state.categoryFeed!);
return _buildCategoryList(
context,
state,
state.categoryFeed!,
localizations,
);
}
return _buildEmptyState(context, localizations);
@@ -221,52 +226,127 @@ class DiscoverDetailsPage extends StatelessWidget {
bool get _isSeriesView => fullPath != null && fullPath!.contains('/series/');
Widget _buildBookGrid(BuildContext context, DiscoverFeedModel feed) {
return BlocBuilder<DiscoverDetailsBloc, DiscoverDetailsState>(
builder: (context, state) {
final viewState = context.watch<BookViewBloc>().state;
final seriesView = _isSeriesView;
Widget _buildBookGrid(
BuildContext context,
DiscoverDetailsState state,
DiscoverFeedModel feed,
AppLocalizations localizations,
) {
final viewState = context.watch<BookViewBloc>().state;
final seriesView = _isSeriesView;
if (viewState.isListView) {
return ListView.builder(
padding: const EdgeInsets.all(16.0),
itemCount: feed.books.length,
itemBuilder:
(context, index) => _buildBookListTile(
context,
feed.books[index],
state,
seriesNumber: seriesView ? index + 1 : null,
),
);
}
return GridView.builder(
padding: const EdgeInsets.all(16.0),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: viewState.columnCount,
childAspectRatio: viewState.columnCount <= 2 ? 0.7 : 0.9,
crossAxisSpacing: 16.0,
mainAxisSpacing: 16.0,
return _buildPaginatedScrollView(
context: context,
state: state,
localizations: localizations,
hasMore: state.hasMoreBooks,
onLoadMore:
() => context.read<DiscoverDetailsBloc>().add(
const LoadMoreDiscoverBooks(),
),
itemCount: feed.books.length,
itemBuilder: (context, index) {
final book = feed.books[index];
return BookCard(
bookId: book.id,
coverUrl: book.coverUrl,
title: book.title,
authors: book.authors,
isLoading: state.loadingBookId == book.id,
onTap: () => _openBook(context, book),
topLeftBadge: seriesView ? '${index + 1}' : null,
);
},
);
},
sliver: SliverPadding(
padding: const EdgeInsets.all(16.0),
sliver:
viewState.isListView
? SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => _buildBookListTile(
context,
feed.books[index],
state,
seriesNumber: seriesView ? index + 1 : null,
),
childCount: feed.books.length,
),
)
: SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: viewState.columnCount,
childAspectRatio: viewState.columnCount <= 2 ? 0.7 : 0.9,
crossAxisSpacing: 16.0,
mainAxisSpacing: 16.0,
),
delegate: SliverChildBuilderDelegate((context, index) {
final book = feed.books[index];
return BookCard(
bookId: book.id,
coverUrl: book.coverUrl,
title: book.title,
authors: book.authors,
isLoading: state.loadingBookId == book.id,
onTap: () => _openBook(context, book),
topLeftBadge: seriesView ? '${index + 1}' : null,
);
}, childCount: feed.books.length),
),
),
);
}
/// Scrollable feed that pulls the next page in as the end comes into reach
Widget _buildPaginatedScrollView({
required BuildContext context,
required DiscoverDetailsState state,
required AppLocalizations localizations,
required bool hasMore,
required VoidCallback onLoadMore,
required Widget sliver,
}) {
return NotificationListener<ScrollNotification>(
onNotification: (notification) {
if (hasMore &&
!state.isLoadingMore &&
notification.metrics.pixels >=
notification.metrics.maxScrollExtent - 600) {
onLoadMore();
}
return false;
},
child: CustomScrollView(
slivers: [
sliver,
SliverToBoxAdapter(
child: _buildPaginationFooter(
context,
state,
localizations,
hasMore: hasMore,
onLoadMore: onLoadMore,
),
),
],
),
);
}
Widget _buildPaginationFooter(
BuildContext context,
DiscoverDetailsState state,
AppLocalizations localizations, {
required bool hasMore,
required VoidCallback onLoadMore,
}) {
if (state.isLoadingMore) {
return const Padding(
padding: EdgeInsets.all(24),
child: Center(child: CircularProgressIndicator()),
);
}
if (hasMore) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
child: Center(
child: OutlinedButton.icon(
onPressed: onLoadMore,
icon: const Icon(Icons.expand_more_rounded),
label: Text(localizations.loadMore),
),
),
);
}
return const SizedBox(height: 16);
}
Widget _buildBookListTile(
BuildContext context,
DiscoverDetailsModel book,
@@ -396,17 +476,31 @@ class DiscoverDetailsPage extends StatelessWidget {
);
}
Widget _buildCategoryList(BuildContext context, CategoryFeed feed) {
return ListView.builder(
itemCount: feed.categories.length,
itemBuilder: (context, index) {
final category = feed.categories[index];
return CategoryListItem(
category: category,
type: categoryType ?? CategoryType.category,
onTap: () => _navigateToCategoryOrBooks(context, category),
);
},
Widget _buildCategoryList(
BuildContext context,
DiscoverDetailsState state,
CategoryFeed feed,
AppLocalizations localizations,
) {
return _buildPaginatedScrollView(
context: context,
state: state,
localizations: localizations,
hasMore: state.hasMoreCategories,
onLoadMore:
() => context.read<DiscoverDetailsBloc>().add(
const LoadMoreDiscoverCategories(),
),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final category = feed.categories[index];
return CategoryListItem(
category: category,
type: categoryType ?? CategoryType.category,
onTap: () => _navigateToCategoryOrBooks(context, category),
);
}, childCount: feed.categories.length),
),
);
}
@@ -98,4 +98,32 @@ void main() {
expect(feed, isNotNull);
expect(feed.categories, isA<List>());
});
test('loadBooks() exposes the next link and that page loads', () async {
await setUpDataSource();
final first = await dataSource.loadBooks(DiscoverType.newlyAdded);
if (first.nextPageUrl == null) {
return;
}
final second = await dataSource.loadBooksFromPath(first.nextPageUrl!);
final firstIds = first.books.map((b) => b.id).toSet();
expect(second.books, isNotEmpty);
expect(second.books.any((b) => !firstIds.contains(b.id)), isTrue);
});
test('loadCategoriesFromPath() follows a category next link', () async {
await setUpDataSource();
final first = await dataSource.loadCategories(CategoryType.author);
if (first.nextPageUrl == null) {
return;
}
final second = await dataSource.loadCategoriesFromPath(first.nextPageUrl!);
expect(second.categories, isNotEmpty);
});
}
@@ -0,0 +1,215 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:calibre_web_companion/features/discover/blocs/discover_event.dart';
import 'package:calibre_web_companion/features/discover_details/bloc/discover_details_bloc.dart';
import 'package:calibre_web_companion/features/discover_details/bloc/discover_details_event.dart';
import 'package:calibre_web_companion/features/discover_details/bloc/discover_details_state.dart';
import 'package:calibre_web_companion/features/discover_details/data/datasources/discover_details_remote_datasource.dart';
import 'package:calibre_web_companion/features/discover_details/data/models/category_feed_model.dart';
import 'package:calibre_web_companion/features/discover_details/data/models/category_model.dart';
import 'package:calibre_web_companion/features/discover_details/data/models/discover_details_model.dart';
import 'package:calibre_web_companion/features/discover_details/data/models/discover_feed_model.dart';
import 'package:calibre_web_companion/features/discover_details/data/repositories/discover_details_repository.dart';
class _FakeRepository implements DiscoverDetailsRepository {
final Map<String, DiscoverFeedModel> bookPages;
final Map<String, CategoryFeed> categoryPages;
final List<String> requestedPaths = [];
_FakeRepository({
this.bookPages = const {},
this.categoryPages = const {},
});
@override
DiscoverDetailsRemoteDatasource get dataSource => throw UnimplementedError();
@override
Future<DiscoverFeedModel> loadBooksFromPath(String fullPath) async {
requestedPaths.add(fullPath);
final page = bookPages[fullPath];
if (page == null) throw Exception('404 for $fullPath');
return page;
}
@override
Future<CategoryFeed> loadCategoriesFromPath(String fullPath) async {
requestedPaths.add(fullPath);
final page = categoryPages[fullPath];
if (page == null) throw Exception('404 for $fullPath');
return page;
}
@override
Future<DiscoverFeedModel> loadBooks(
DiscoverType type, {
String? subPath,
}) async => throw UnimplementedError();
@override
Future<CategoryFeed> loadCategories(
CategoryType type, {
String? subPath,
}) async => categoryPages['first'] ?? const CategoryFeed(categories: []);
}
DiscoverDetailsModel book(String id) =>
DiscoverDetailsModel(id: id, uuid: 'uuid-$id', title: id, authors: 'A');
Future<DiscoverDetailsState> loaded(DiscoverDetailsBloc bloc) =>
bloc.stream.firstWhere((s) => s.status == DiscoverDetailsStatus.loaded);
Future<DiscoverDetailsState> settled(DiscoverDetailsBloc bloc) =>
bloc.stream.firstWhere((s) => !s.isLoadingMore);
void main() {
test('load more appends the next page and carries its next link', () async {
final repository = _FakeRepository(
bookPages: {
'/opds/series/1': DiscoverFeedModel(
books: [book('1'), book('2')],
nextPageUrl: '/opds/series/1?offset=2',
),
'/opds/series/1?offset=2': DiscoverFeedModel(
books: [book('3')],
nextPageUrl: '/opds/series/1?offset=4',
),
},
);
final bloc = DiscoverDetailsBloc(repository: repository);
bloc.add(const LoadBooksFromPath('/opds/series/1'));
final first = await loaded(bloc);
expect(first.bookFeed!.books, hasLength(2));
expect(first.hasMoreBooks, isTrue);
bloc.add(const LoadMoreDiscoverBooks());
final second = await settled(bloc);
expect(second.bookFeed!.books.map((b) => b.id), ['1', '2', '3']);
expect(second.bookFeed!.nextPageUrl, '/opds/series/1?offset=4');
expect(second.status, DiscoverDetailsStatus.loaded);
await bloc.close();
});
test('load more drops books that are already in the feed', () async {
final repository = _FakeRepository(
bookPages: {
'/opds/new': DiscoverFeedModel(
books: [book('1'), book('2')],
nextPageUrl: '/opds/new?offset=2',
),
'/opds/new?offset=2': DiscoverFeedModel(books: [book('2'), book('3')]),
},
);
final bloc = DiscoverDetailsBloc(repository: repository);
bloc.add(const LoadBooksFromPath('/opds/new'));
await loaded(bloc);
bloc.add(const LoadMoreDiscoverBooks());
final state = await settled(bloc);
expect(state.bookFeed!.books.map((b) => b.id), ['1', '2', '3']);
expect(state.hasMoreBooks, isFalse);
await bloc.close();
});
test('a next link pointing at the page just loaded ends pagination', () async {
final repository = _FakeRepository(
bookPages: {
'/opds/new': DiscoverFeedModel(
books: [book('1')],
nextPageUrl: '/opds/new?offset=1',
),
'/opds/new?offset=1': DiscoverFeedModel(
books: [book('2')],
nextPageUrl: '/opds/new?offset=1',
),
},
);
final bloc = DiscoverDetailsBloc(repository: repository);
bloc.add(const LoadBooksFromPath('/opds/new'));
await loaded(bloc);
bloc.add(const LoadMoreDiscoverBooks());
final state = await settled(bloc);
expect(state.bookFeed!.books, hasLength(2));
expect(state.hasMoreBooks, isFalse);
await bloc.close();
});
test('load more without a next link does not hit the repository', () async {
final repository = _FakeRepository(
bookPages: {'/opds/new': DiscoverFeedModel(books: [book('1')])},
);
final bloc = DiscoverDetailsBloc(repository: repository);
bloc.add(const LoadBooksFromPath('/opds/new'));
await loaded(bloc);
bloc.add(const LoadMoreDiscoverBooks());
await Future<void>.delayed(Duration.zero);
expect(repository.requestedPaths, ['/opds/new']);
await bloc.close();
});
test('a failing next page keeps the books already shown', () async {
final repository = _FakeRepository(
bookPages: {
'/opds/new': DiscoverFeedModel(
books: [book('1')],
nextPageUrl: '/opds/new?offset=1',
),
},
);
final bloc = DiscoverDetailsBloc(repository: repository);
bloc.add(const LoadBooksFromPath('/opds/new'));
await loaded(bloc);
bloc.add(const LoadMoreDiscoverBooks());
final state = await settled(bloc);
expect(state.bookFeed!.books, hasLength(1));
expect(state.status, DiscoverDetailsStatus.loaded);
await bloc.close();
});
test('categories paginate the same way as books', () async {
final repository = _FakeRepository(
categoryPages: {
'first': const CategoryFeed(
categories: [CategoryModel(id: '/opds/series/1', title: 'A Series')],
nextPageUrl: '/opds/series/letter/A?offset=1',
),
'/opds/series/letter/A?offset=1': const CategoryFeed(
categories: [CategoryModel(id: '/opds/series/2', title: 'B Series')],
),
},
);
final bloc = DiscoverDetailsBloc(repository: repository);
bloc.add(const LoadCategories(CategoryType.series, subPath: 'letter/A'));
await loaded(bloc);
bloc.add(const LoadMoreDiscoverCategories());
final state = await settled(bloc);
expect(state.categoryFeed!.categories.map((c) => c.title), [
'A Series',
'B Series',
]);
expect(state.hasMoreCategories, isFalse);
await bloc.close();
});
}