worldhopper/lib/repositories/feed_repository.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

145 lines
4.3 KiB
Dart

import 'package:dio/dio.dart';
import 'package:worldhopper/models/feed.dart';
import 'package:worldhopper/services/server_software/server_software_service.dart';
import 'package:worldhopper/services/url_helper.dart';
import 'package:worldhopper/repositories/server_repository.dart';
/// Repository for managing server feeds with caching.
///
/// Dispatches to the appropriate [ServerSoftware] implementation based on
/// the server's software type.
class FeedRepository {
final ServerSoftwareService _softwareService;
final ServerRepository _serverRepository;
// Simple in-memory cache
final Map<String, Feed> _feedCache = {};
final Map<String, DateTime> _cacheTimestamps = {};
final Duration _cacheDuration = const Duration(minutes: 5);
FeedRepository({
ServerSoftwareService? softwareService,
ServerRepository? serverRepository,
}) : _softwareService = softwareService ?? ServerSoftwareService(),
_serverRepository = serverRepository ?? ServerRepository();
/// Fetch a feed with caching.
Future<Feed> fetchFeed(
String serverId,
String url, {
bool forceRefresh = false,
CancelToken? cancelToken,
}) async {
final server = await _serverRepository.getServer(serverId);
if (server == null) {
throw Exception('Server not found: $serverId');
}
// Resolve relative URLs against server base URL, but skip for
// synthetic URLs (e.g., kavita://...) which are already absolute.
final resolvedUrl = url.startsWith('kavita://')
? url
: UrlHelper.resolveUrl(server.url, url);
final cacheKey = '$serverId:$resolvedUrl';
// Check cache if not forcing refresh
if (!forceRefresh && _isCached(cacheKey)) {
return _feedCache[cacheKey]!;
}
// Dispatch to the right ServerSoftware implementation
final software = _softwareService.getImplementation(server);
final parsedFeed = await software.fetchFeed(
server,
resolvedUrl,
cancelToken: cancelToken,
);
final feed = parsedFeed.feed;
// Update cache
_feedCache[cacheKey] = feed;
_cacheTimestamps[cacheKey] = DateTime.now();
// Update server's last synced timestamp
await _serverRepository.updateLastSynced(serverId, DateTime.now());
return feed;
}
/// Fetch the root feed for a server.
Future<Feed> fetchRootFeed(
String serverId, {
bool forceRefresh = false,
CancelToken? cancelToken,
}) async {
final server = await _serverRepository.getServer(serverId);
if (server == null) {
throw Exception('Server not found: $serverId');
}
final cacheKey = '$serverId:__root__';
if (!forceRefresh && _isCached(cacheKey)) {
return _feedCache[cacheKey]!;
}
final software = _softwareService.getImplementation(server);
final parsedFeed = await software.fetchRootFeed(
server,
cancelToken: cancelToken,
);
final feed = parsedFeed.feed;
_feedCache[cacheKey] = feed;
_cacheTimestamps[cacheKey] = DateTime.now();
await _serverRepository.updateLastSynced(serverId, DateTime.now());
return feed;
}
/// Test connection to a server.
Future<bool> testConnection(String serverId) async {
final server = await _serverRepository.getServer(serverId);
if (server == null) {
throw Exception('Server not found: $serverId');
}
final software = _softwareService.getImplementation(server);
return await software.testConnection(server);
}
/// Clear cache for a specific feed.
void clearFeedCache(String serverId, String url) {
final cacheKey = '$serverId:$url';
_feedCache.remove(cacheKey);
_cacheTimestamps.remove(cacheKey);
}
/// Clear cache for a specific server.
void clearServerCache(String serverId) {
_feedCache.removeWhere((key, _) => key.startsWith('$serverId:'));
_cacheTimestamps.removeWhere((key, _) => key.startsWith('$serverId:'));
}
/// Clear all cache.
void clearAllCache() {
_feedCache.clear();
_cacheTimestamps.clear();
}
/// Check if a feed is cached and still valid.
bool _isCached(String cacheKey) {
if (!_feedCache.containsKey(cacheKey)) {
return false;
}
final timestamp = _cacheTimestamps[cacheKey];
if (timestamp == null) {
return false;
}
final age = DateTime.now().difference(timestamp);
return age < _cacheDuration;
}
}