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>
165 lines
4.6 KiB
Dart
165 lines
4.6 KiB
Dart
import 'package:dio/dio.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:worldhopper/models/opds_server.dart';
|
|
import 'package:worldhopper/services/auth_service.dart';
|
|
import 'package:worldhopper/services/opds_parser.dart' show OPDSParser, ParsedFeed;
|
|
|
|
/// Service for fetching OPDS feeds over HTTP
|
|
class OPDSService {
|
|
final AuthService _authService;
|
|
final OPDSParser _parser;
|
|
final Map<String, Dio> _clients = {};
|
|
|
|
OPDSService({
|
|
AuthService? authService,
|
|
OPDSParser? parser,
|
|
}) : _authService = authService ?? AuthService(),
|
|
_parser = parser ?? OPDSParser();
|
|
|
|
/// Get or create a Dio client for a server
|
|
Dio _getClient(OPDSServer server) {
|
|
if (!_clients.containsKey(server.id)) {
|
|
_clients[server.id] = _authService.createAuthenticatedClient(server);
|
|
}
|
|
return _clients[server.id]!;
|
|
}
|
|
|
|
/// Fetch and parse an OPDS feed from a URL
|
|
Future<ParsedFeed> fetchFeed(
|
|
OPDSServer server,
|
|
String url, {
|
|
CancelToken? cancelToken,
|
|
}) async {
|
|
try {
|
|
final client = _getClient(server);
|
|
|
|
// Log the URL being fetched for debugging
|
|
debugPrint('Fetching OPDS feed: $url');
|
|
|
|
final response = await client.get(
|
|
url,
|
|
cancelToken: cancelToken,
|
|
options: Options(
|
|
responseType: ResponseType.plain,
|
|
),
|
|
);
|
|
|
|
if (response.statusCode == 200) {
|
|
final xmlContent = response.data as String;
|
|
return _parser.parseFeed(xmlContent, server: server);
|
|
} else {
|
|
throw OPDSException(
|
|
'Failed to fetch feed: HTTP ${response.statusCode}',
|
|
statusCode: response.statusCode,
|
|
);
|
|
}
|
|
} on DioException catch (e) {
|
|
debugPrint('DioException fetching feed from $url: ${e.type}, ${e.message}');
|
|
throw _handleDioException(e);
|
|
} catch (e) {
|
|
debugPrint('Error fetching feed from $url: $e');
|
|
throw OPDSException('Error fetching feed: $e');
|
|
}
|
|
}
|
|
|
|
/// Fetch the root feed for a server
|
|
Future<ParsedFeed> fetchRootFeed(
|
|
OPDSServer server, {
|
|
CancelToken? cancelToken,
|
|
}) async {
|
|
return fetchFeed(server, server.url, cancelToken: cancelToken);
|
|
}
|
|
|
|
/// Test connection to a server
|
|
Future<bool> testConnection(
|
|
OPDSServer server, {
|
|
CancelToken? cancelToken,
|
|
}) async {
|
|
try {
|
|
await fetchRootFeed(server, cancelToken: cancelToken);
|
|
return true;
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// Clear cached clients (useful when server credentials change)
|
|
void clearClients() {
|
|
_clients.clear();
|
|
}
|
|
|
|
/// Clear client for a specific server
|
|
void clearClient(String serverId) {
|
|
_clients.remove(serverId);
|
|
}
|
|
|
|
/// Handle Dio exceptions and convert to OPDSException
|
|
OPDSException _handleDioException(DioException e) {
|
|
switch (e.type) {
|
|
case DioExceptionType.connectionTimeout:
|
|
case DioExceptionType.sendTimeout:
|
|
case DioExceptionType.receiveTimeout:
|
|
return OPDSException(
|
|
'Connection timeout. Please check your network connection.',
|
|
statusCode: null,
|
|
isNetworkError: true,
|
|
);
|
|
|
|
case DioExceptionType.badResponse:
|
|
final statusCode = e.response?.statusCode;
|
|
if (statusCode == 401 || statusCode == 403) {
|
|
return OPDSException(
|
|
'Authentication failed. Please check your credentials.',
|
|
statusCode: statusCode,
|
|
isAuthError: true,
|
|
);
|
|
} else if (statusCode == 404) {
|
|
return OPDSException(
|
|
'Feed not found (404).',
|
|
statusCode: statusCode,
|
|
);
|
|
} else {
|
|
return OPDSException(
|
|
'Server error: HTTP $statusCode',
|
|
statusCode: statusCode,
|
|
);
|
|
}
|
|
|
|
case DioExceptionType.cancel:
|
|
return OPDSException('Request cancelled');
|
|
|
|
case DioExceptionType.connectionError:
|
|
return OPDSException(
|
|
'Connection error: ${e.message ?? "Could not connect to server"}',
|
|
isNetworkError: true,
|
|
);
|
|
|
|
case DioExceptionType.unknown:
|
|
return OPDSException(
|
|
'Network error: ${e.message ?? e.error?.toString() ?? "Unknown error"}',
|
|
isNetworkError: true,
|
|
);
|
|
|
|
default:
|
|
return OPDSException('Error: ${e.message ?? "Unknown error"}');
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Custom exception for OPDS-related errors
|
|
class OPDSException implements Exception {
|
|
final String message;
|
|
final int? statusCode;
|
|
final bool isNetworkError;
|
|
final bool isAuthError;
|
|
|
|
OPDSException(
|
|
this.message, {
|
|
this.statusCode,
|
|
this.isNetworkError = false,
|
|
this.isAuthError = false,
|
|
});
|
|
|
|
@override
|
|
String toString() => message;
|
|
}
|