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
200 lines
5.9 KiB
Dart
200 lines
5.9 KiB
Dart
import 'package:dio/dio.dart';
|
||
import 'package:flutter/foundation.dart';
|
||
import 'package:worldhopper/models/server.dart';
|
||
import 'package:worldhopper/models/stream_link.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(Server 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(
|
||
Server 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 ServerException(
|
||
'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 ServerException('Error fetching feed: $e');
|
||
}
|
||
}
|
||
|
||
/// Fetch the root feed for a server
|
||
Future<ParsedFeed> fetchRootFeed(
|
||
Server server, {
|
||
CancelToken? cancelToken,
|
||
}) async {
|
||
return fetchFeed(server, server.url, cancelToken: cancelToken);
|
||
}
|
||
|
||
/// Test connection to a server. Throws on failure with details.
|
||
Future<bool> testConnection(
|
||
Server server, {
|
||
CancelToken? cancelToken,
|
||
}) async {
|
||
await fetchRootFeed(server, cancelToken: cancelToken);
|
||
return true;
|
||
}
|
||
|
||
/// Fire a GET request for a PSE page URL so the server registers the page
|
||
/// view (e.g. Kavita tracks reading progress from GET requests).
|
||
///
|
||
/// Uses [ResponseType.stream] and drains the body immediately so the full
|
||
/// image is never held in memory. Errors are logged but never thrown.
|
||
Future<void> reportPageProgress(
|
||
Server server,
|
||
StreamLink streamLink,
|
||
int page,
|
||
) async {
|
||
try {
|
||
final client = _getClient(server);
|
||
final url = streamLink.getPageUrl(page);
|
||
final response = await client.get<ResponseBody>(
|
||
url,
|
||
options: Options(responseType: ResponseType.stream),
|
||
);
|
||
// Drain the stream so the connection is released.
|
||
await response.data?.stream.drain<void>();
|
||
} catch (e) {
|
||
debugPrint('reportPageProgress: failed for page $page – $e');
|
||
}
|
||
}
|
||
|
||
/// Report the last page as read — convenience wrapper used when a book is
|
||
/// finished.
|
||
Future<void> markAsRead(
|
||
Server server,
|
||
StreamLink streamLink,
|
||
) async {
|
||
await reportPageProgress(server, streamLink, streamLink.pageCount - 1);
|
||
}
|
||
|
||
/// 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 ServerException
|
||
ServerException _handleDioException(DioException e) {
|
||
switch (e.type) {
|
||
case DioExceptionType.connectionTimeout:
|
||
case DioExceptionType.sendTimeout:
|
||
case DioExceptionType.receiveTimeout:
|
||
return ServerException(
|
||
'Connection timeout. Please check your network connection.',
|
||
statusCode: null,
|
||
isNetworkError: true,
|
||
);
|
||
|
||
case DioExceptionType.badResponse:
|
||
final statusCode = e.response?.statusCode;
|
||
final body = e.response?.data?.toString() ?? '';
|
||
final bodySnippet =
|
||
body.length > 200 ? '${body.substring(0, 200)}...' : body;
|
||
if (statusCode == 401 || statusCode == 403) {
|
||
return ServerException(
|
||
'Authentication failed (HTTP $statusCode).\n$bodySnippet',
|
||
statusCode: statusCode,
|
||
isAuthError: true,
|
||
);
|
||
} else if (statusCode == 404) {
|
||
return ServerException(
|
||
'Feed not found (404).\n$bodySnippet',
|
||
statusCode: statusCode,
|
||
);
|
||
} else {
|
||
return ServerException(
|
||
'Server error: HTTP $statusCode\n$bodySnippet',
|
||
statusCode: statusCode,
|
||
);
|
||
}
|
||
|
||
case DioExceptionType.cancel:
|
||
return ServerException('Request cancelled');
|
||
|
||
case DioExceptionType.connectionError:
|
||
return ServerException(
|
||
'Connection error: ${e.message ?? "Could not connect to server"}',
|
||
isNetworkError: true,
|
||
);
|
||
|
||
case DioExceptionType.unknown:
|
||
return ServerException(
|
||
'Network error: ${e.message ?? e.error?.toString() ?? "Unknown error"}',
|
||
isNetworkError: true,
|
||
);
|
||
|
||
default:
|
||
return ServerException('Error: ${e.message ?? "Unknown error"}');
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Custom exception for OPDS-related errors
|
||
class ServerException implements Exception {
|
||
final String message;
|
||
final int? statusCode;
|
||
final bool isNetworkError;
|
||
final bool isAuthError;
|
||
|
||
ServerException(
|
||
this.message, {
|
||
this.statusCode,
|
||
this.isNetworkError = false,
|
||
this.isAuthError = false,
|
||
});
|
||
|
||
@override
|
||
String toString() => message;
|
||
}
|