feat: navigation store feat: implement OPDS entry caching system Add comprehensive caching for OPDS publications with local image storage and Dublin Core Terms metadata extraction. This enables offline viewing, fixes Library screen to show book titles and covers, and improves overall UX. - Add publications table with full metadata (title, authors, series, publisher, ISBN, language) - Implement local image cache service for covers and thumbnails - Extract DCTerms metadata (series, publisher, language, ISBN) from OPDS feeds - Link reading progress to cached publications - Update Library screen to display cached publication data and covers - Cache publications automatically when starting to read - Fix navigation stack issues by using context.push() instead of context.go() - Add explicit back button to EPUB reader with proper PopScope handling - Implement UNIQUE constraint on reading_progress to prevent duplicates - Save reading progress on screen dispose for both EPUB and image readers Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
151 lines
4.9 KiB
Dart
151 lines
4.9 KiB
Dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
|
import 'package:uuid/uuid.dart';
|
|
import 'package:worldhopper/database/database.dart';
|
|
import 'package:worldhopper/models/enhanced_metadata.dart';
|
|
import 'package:worldhopper/models/opds_entry.dart';
|
|
import 'package:worldhopper/models/publication.dart';
|
|
import 'package:worldhopper/repositories/publication_repository.dart';
|
|
import 'package:worldhopper/repositories/reading_progress_repository.dart';
|
|
import 'package:worldhopper/services/image_cache_service.dart';
|
|
|
|
part 'publication_cache_provider.g.dart';
|
|
|
|
/// Provider for PublicationRepository instance
|
|
@riverpod
|
|
PublicationRepository publicationRepository(PublicationRepositoryRef ref) {
|
|
return PublicationRepository(DatabaseHelper.instance);
|
|
}
|
|
|
|
/// Provider for ImageCacheService instance
|
|
@riverpod
|
|
Future<ImageCacheService> imageCacheService(ImageCacheServiceRef ref) async {
|
|
return await ImageCacheService.create();
|
|
}
|
|
|
|
/// Provider for ReadingProgressRepository instance (if not already provided elsewhere)
|
|
@riverpod
|
|
ReadingProgressRepository readingProgressRepository(
|
|
ReadingProgressRepositoryRef ref) {
|
|
return ReadingProgressRepository();
|
|
}
|
|
|
|
/// Provider for publication cache operations
|
|
@riverpod
|
|
class PublicationCache extends _$PublicationCache {
|
|
@override
|
|
FutureOr<void> build() {}
|
|
|
|
/// Cache a publication from OPDS entry
|
|
Future<Publication> cachePublication({
|
|
required String serverId,
|
|
required OPDSEntry entry,
|
|
required EnhancedMetadata metadata,
|
|
bool downloadImages = true,
|
|
}) async {
|
|
final repository = ref.read(publicationRepositoryProvider);
|
|
final imageService = await ref.read(imageCacheServiceProvider.future);
|
|
|
|
// Check if already cached
|
|
final existing = await repository.getPublication(serverId, entry.id);
|
|
|
|
String? coverPath = existing?.coverPath;
|
|
String? thumbnailPath = existing?.thumbnailPath;
|
|
|
|
// Download images if needed
|
|
if (downloadImages) {
|
|
final publicationId = existing?.id ?? const Uuid().v4();
|
|
|
|
if (entry.coverUrl != null && coverPath == null) {
|
|
coverPath = await imageService.cacheCoverImage(
|
|
publicationId,
|
|
entry.coverUrl!,
|
|
);
|
|
}
|
|
|
|
if (entry.thumbnailUrl != null && thumbnailPath == null) {
|
|
thumbnailPath = await imageService.cacheThumbnailImage(
|
|
publicationId,
|
|
entry.thumbnailUrl!,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Create or update publication
|
|
final publication = existing != null
|
|
? existing.copyWith(
|
|
title: entry.title,
|
|
authors: entry.authors,
|
|
summary: entry.summary,
|
|
content: entry.content,
|
|
coverPath: coverPath ?? existing.coverPath,
|
|
thumbnailPath: thumbnailPath ?? existing.thumbnailPath,
|
|
coverUrl: entry.coverUrl,
|
|
thumbnailUrl: entry.thumbnailUrl,
|
|
series: metadata.series,
|
|
seriesPosition: metadata.seriesPosition,
|
|
publisher: metadata.publisher,
|
|
language: metadata.language,
|
|
isbn: metadata.isbn,
|
|
categories: entry.categories,
|
|
links: entry.links,
|
|
streamLink: entry.streamLink,
|
|
published: entry.published,
|
|
updated: entry.updated,
|
|
lastCachedAt: DateTime.now(),
|
|
lastAccessedAt: DateTime.now(),
|
|
)
|
|
: Publication.fromOPDSEntry(
|
|
id: const Uuid().v4(),
|
|
serverId: serverId,
|
|
entry: entry,
|
|
metadata: metadata,
|
|
coverPath: coverPath,
|
|
thumbnailPath: thumbnailPath,
|
|
);
|
|
|
|
await repository.savePublication(publication);
|
|
return publication;
|
|
}
|
|
|
|
/// Update last accessed timestamp
|
|
Future<void> touchPublication(String publicationId) async {
|
|
final repository = ref.read(publicationRepositoryProvider);
|
|
await repository.updateLastAccessed(publicationId);
|
|
}
|
|
|
|
/// Clean up stale cache entries
|
|
Future<int> cleanupStaleCache() async {
|
|
final repository = ref.read(publicationRepositoryProvider);
|
|
final imageService = await ref.read(imageCacheServiceProvider.future);
|
|
final progressRepo = ref.read(readingProgressRepositoryProvider);
|
|
|
|
final stalePublications = await repository.getStalePublications();
|
|
int deletedCount = 0;
|
|
|
|
for (final pub in stalePublications) {
|
|
// Check if linked to reading progress
|
|
final hasProgress =
|
|
await progressRepo.getProgress(pub.opdsId, pub.serverId);
|
|
|
|
if (hasProgress == null) {
|
|
// Safe to delete
|
|
await imageService.deleteCachedImages(pub.id);
|
|
await repository.deletePublication(pub.id);
|
|
deletedCount++;
|
|
}
|
|
}
|
|
|
|
return deletedCount;
|
|
}
|
|
}
|
|
|
|
/// Provider for single publication lookup
|
|
@riverpod
|
|
Future<Publication?> cachedPublication(
|
|
CachedPublicationRef ref,
|
|
String serverId,
|
|
String opdsId,
|
|
) async {
|
|
final repository = ref.watch(publicationRepositoryProvider);
|
|
return repository.getPublication(serverId, opdsId);
|
|
}
|