worldhopper/lib/services/opds_service.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

200 lines
5.9 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
}