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 _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 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 fetchRootFeed( Server server, { CancelToken? cancelToken, }) async { return fetchFeed(server, server.url, cancelToken: cancelToken); } /// Test connection to a server. Throws on failure with details. Future 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 reportPageProgress( Server server, StreamLink streamLink, int page, ) async { try { final client = _getClient(server); final url = streamLink.getPageUrl(page); final response = await client.get( url, options: Options(responseType: ResponseType.stream), ); // Drain the stream so the connection is released. await response.data?.stream.drain(); } catch (e) { debugPrint('reportPageProgress: failed for page $page – $e'); } } /// Report the last page as read — convenience wrapper used when a book is /// finished. Future 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; }