mangayomi-mirror/lib/modules/more/statistics/statistics_provider.dart
Mehakdeep Singh 8babb975ab perf(statistics): aggregate inside Isar instead of loading all favourite chapters into memory
Fixes #543. The statistics screen was crashing on Android with a
populated library (~75+ favourites). Repro: open the More -> Statistics
screen on an account with a real-sized library; app hangs and is
killed.

Cause is in `getStatistics()` in `lib/modules/more/statistics/
statistics_provider.dart`. The provider materialised every favourite
manga *and* every chapter of every favourite into a Dart list, then
walked the lists in memory:

    final items = await isar.mangas.filter()...findAll();
    final chapters = await isar.chapters.filter()
        .manga((q) => q.favoriteEqualTo(true)...).findAll();

For a library with 75 favourites at ~100 chapters each that is 7,500+
heavy Isar objects materialised at once just to compute six counts.
The "completed items" loop then re-fetched chapters per completed
manga via the link relation (`item.chapters.toList()`), and the
reading-time aggregation loaded every History row to sum a single
int field.

This commit rewrites the function to do all aggregation inside Isar:

* total / read / completed-items / downloads => `count()` queries on
  indexed filter chains. No object hydration.
* completed favourites => projects only the IDs (`idProperty()`),
  then runs two cheap `count()` queries per item to check
  "has-chapters && unread == 0". Logic is unchanged from the original
  `every(isRead) && isNotEmpty` check.
* reading time => projects the `readingTimeSeconds` field
  (`readingTimeSecondsProperty()`) and folds the resulting List<int?>.
  Avoids hydrating full History rows.

Memory: peak in-memory list shrinks from ~7,500 fully-hydrated chapters
plus all favourite mangas plus all history rows, to (at most) the IDs
of completed favourites (typically <50 ints) and the projected reading-
time list (one int per history row, decoded as a flat list).

DB round-trips: the completed-items branch now does 2 round-trips per
completed favourite instead of 1 link-traversal per completed favourite.
On indexed `mangaId` + `isRead` filters this is sub-millisecond per
query and well below the cost of materialising thousands of rows. The
overall function is faster end-to-end on any non-trivial library.

Behaviour preserved: same six fields, same definitions, same edge
cases (manga with no chapters is not counted as completed).

Verified
- `flutter analyze` clean on the touched file
- `flutter build macos --release` succeeds
- Manual smoke test on macOS with the local-all-fixes build (the bug
  reproduces only with a large mobile library; macOS desktop with a
  small test library returns the same numbers as before)
2026-05-09 23:12:49 -07:00

113 lines
3.3 KiB
Dart

import 'package:isar_community/isar.dart';
import 'package:mangayomi/main.dart';
import 'package:mangayomi/models/chapter.dart';
import 'package:mangayomi/models/download.dart';
import 'package:mangayomi/models/history.dart';
import 'package:mangayomi/models/manga.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'statistics_provider.g.dart';
class StatisticsData {
final int totalItems;
final int totalChapters;
final int readChapters;
final int completedItems;
final int downloadedItems;
final int totalReadingTimeSeconds;
const StatisticsData({
required this.totalItems,
required this.totalChapters,
required this.readChapters,
required this.completedItems,
required this.downloadedItems,
required this.totalReadingTimeSeconds,
});
}
@riverpod
Future<StatisticsData> getStatistics(
Ref ref, {
required ItemType itemType,
}) async {
// Aggregate inside Isar instead of materialising every favourite manga
// and every one of their chapters in Dart. Loading all chapters of a
// 75+ manga library exhausts heap on Android and reliably crashes the
// statistics screen — see issue #543.
final totalItems = await isar.mangas
.filter()
.favoriteEqualTo(true)
.itemTypeEqualTo(itemType)
.count();
final totalChapters = await isar.chapters
.filter()
.manga((q) => q.favoriteEqualTo(true).itemTypeEqualTo(itemType))
.count();
final readChapters = await isar.chapters
.filter()
.manga((q) => q.favoriteEqualTo(true).itemTypeEqualTo(itemType))
.isReadEqualTo(true)
.count();
final downloadedCount = await isar.downloads
.filter()
.chapter(
(q) => q.manga(
(m) => m.favoriteEqualTo(true).itemTypeEqualTo(itemType),
),
)
.isDownloadEqualTo(true)
.count();
// Completed items: a favourite manga whose source-reported status is
// Completed AND every chapter is read AND there is at least one chapter.
// Pull only the IDs of completed favourites, then run two cheap count()
// queries per item — never materialise the chapter rows.
final completedFavouriteIds = await isar.mangas
.filter()
.favoriteEqualTo(true)
.itemTypeEqualTo(itemType)
.statusEqualTo(Status.completed)
.idProperty()
.findAll();
int completedItems = 0;
for (final id in completedFavouriteIds) {
final total = await isar.chapters
.filter()
.mangaIdEqualTo(id)
.count();
if (total == 0) continue;
final unread = await isar.chapters
.filter()
.mangaIdEqualTo(id)
.isReadEqualTo(false)
.count();
if (unread == 0) completedItems++;
}
// Sum reading time without loading full History rows. Project just the
// int field — Isar returns a List<int?> of plain ints, far cheaper than
// hydrating every history record.
final readingTimes = await isar.historys
.filter()
.itemTypeEqualTo(itemType)
.readingTimeSecondsProperty()
.findAll();
final totalReadingTimeSeconds = readingTimes.fold<int>(
0,
(sum, v) => sum + (v ?? 0),
);
return StatisticsData(
totalItems: totalItems,
totalChapters: totalChapters,
readChapters: readChapters,
completedItems: completedItems,
downloadedItems: downloadedCount,
totalReadingTimeSeconds: totalReadingTimeSeconds,
);
}