worldhopper/lib/providers/publication_cache_provider.dart
Felipe M. 58a562abe8
feat: refactor server software abstraction with Kavita REST API integration
Rename all OPDS-prefixed models to generic names (Server, Feed, Entry,
Link, StreamLink), expand the ServerSoftware interface to cover all
server interactions, and implement a full Kavita REST API client that
replaces the OPDS delegation.

- Rename OPDS* models to generic names across ~60 files
- Add database migrations 13 (opds_id → entry_id) and 14 (unify credentials)
- Create OPDSServerSoftware wrapping existing OPDS services
- Create KavitaApiClient for direct Kavita REST API calls
- Create KavitaFeedMapper to convert Kavita JSON to Feed/Entry models
- Rewrite KavitaServerSoftware to use native API (no OPDS delegation)
- Unify server credentials (remove softwareUsername/softwarePassword)
- Simplify add/edit server UI to single auth section
- Add test connection button to server add/edit screens
- Add progress indicator to PublicationCard using local and server data
- Eliminate softwareType branching in reader screens
- Add EntryProgress and fetchEntryProgress to ServerSoftware interface
- Fix Entry.acquisitionLink crash on empty links
- Add 59 new tests covering models, services, and providers
2026-04-06 17:46:48 +02:00

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/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 Entry 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.fromEntry(
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.entryId, 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 entryId,
) async {
final repository = ref.watch(publicationRepositoryProvider);
return repository.getPublication(serverId, entryId);
}