import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import 'package:worldhopper/models/server.dart'; import 'package:worldhopper/services/dio_factory.dart'; /// Cached authentication data for a Kavita server. class KavitaAuth { final String token; final String apiKey; final DateTime expiry; const KavitaAuth({ required this.token, required this.apiKey, required this.expiry, }); bool get isExpired => DateTime.now().isAfter(expiry); } /// Paginated result from Kavita API. class KavitaPaginatedResult { final List> items; final int totalItems; const KavitaPaginatedResult({ required this.items, required this.totalItems, }); } /// HTTP client for the Kavita REST API. /// /// Handles JWT authentication with caching, and provides typed methods /// for each Kavita endpoint used by the app. class KavitaApiClient { final Dio _dio; /// In-memory auth cache: serverId → KavitaAuth. final Map _authCache = {}; /// Auth cache lifetime (29 days; Kavita tokens last 30). static const _authTtl = Duration(days: 29); KavitaApiClient({Dio? dio}) : _dio = dio ?? createDio(); // --------------------------------------------------------------------------- // Authentication // --------------------------------------------------------------------------- /// Get or refresh authentication for a server. Returns JWT + apiKey. Future getAuth(Server server) async { final cached = _authCache[server.id]; if (cached != null && !cached.isExpired) { return cached; } if (server.username == null || server.password == null) { throw KavitaApiException('No credentials configured for server'); } debugPrint('KavitaApi: logging in to ${server.url}'); final Response response; try { response = await _dio.post( '${server.url}/api/Account/login', data: { 'username': server.username, 'password': server.password, }, ); } on DioException catch (e) { final body = e.response?.data?.toString() ?? ''; final status = e.response?.statusCode; throw KavitaApiException( 'Login failed: HTTP $status\n$body', statusCode: status, ); } final data = response.data as Map; final token = data['token'] as String?; final apiKey = data['apiKey'] as String?; if (token == null || apiKey == null) { throw KavitaApiException( 'Login response missing token or apiKey\n${response.data}'); } final auth = KavitaAuth( token: token, apiKey: apiKey, expiry: DateTime.now().add(_authTtl), ); _authCache[server.id] = auth; return auth; } /// Clear cached auth for a server. void clearAuth(String serverId) { _authCache.remove(serverId); } // --------------------------------------------------------------------------- // Library // --------------------------------------------------------------------------- /// GET /api/Library/libraries — list all libraries. Future>> getLibraries(Server server) async { final auth = await getAuth(server); final response = await _dio.get( '${server.url}/api/Library/libraries', options: _authOptions(auth), ); return (response.data as List).cast>(); } // --------------------------------------------------------------------------- // Series // --------------------------------------------------------------------------- /// POST /api/Series/v2 — list series filtered by library, paginated. Future getSeries( Server server, { required int libraryId, int page = 1, int pageSize = 20, }) async { final auth = await getAuth(server); final response = await _dio.post( '${server.url}/api/Series/v2', queryParameters: { 'PageNumber': page, 'PageSize': pageSize, }, data: { 'statements': [ { 'field': 19, // FilterField.Libraries 'comparison': 0, // FilterComparison.Equal 'value': '$libraryId', }, ], 'combination': 0, // And 'sortOptions': { 'sortField': 1, // SortName 'isAscending': true, }, 'limitTo': 0, }, options: _authOptions(auth), ); final items = (response.data as List).cast>(); // Kavita returns total count in pagination headers final totalStr = response.headers.value('pagination'); int totalItems = items.length; if (totalStr != null) { try { // Pagination header is JSON: {"currentPage":1,"itemsPerPage":20,"totalItems":42,"totalPages":3} // Parse totalItems from it final paginationMatch = RegExp(r'"totalItems"\s*:\s*(\d+)').firstMatch(totalStr); if (paginationMatch != null) { totalItems = int.parse(paginationMatch.group(1)!); } } catch (_) { // Ignore parsing errors } } return KavitaPaginatedResult(items: items, totalItems: totalItems); } /// GET /api/Series/series-detail — get volumes and chapters for a series. Future> getSeriesDetail( Server server, { required int seriesId, }) async { final auth = await getAuth(server); final response = await _dio.get( '${server.url}/api/Series/series-detail', queryParameters: {'seriesId': seriesId}, options: _authOptions(auth), ); return response.data as Map; } /// GET /api/Series/{seriesId} — get series metadata. Future> getSeriesInfo( Server server, { required int seriesId, }) async { final auth = await getAuth(server); final response = await _dio.get( '${server.url}/api/Series/$seriesId', options: _authOptions(auth), ); return response.data as Map; } // --------------------------------------------------------------------------- // Reading // --------------------------------------------------------------------------- /// GET /api/Reader/chapter-info — get page count and metadata for a chapter. Future> getChapterInfo( Server server, { required int chapterId, }) async { final auth = await getAuth(server); final response = await _dio.get( '${server.url}/api/Reader/chapter-info', queryParameters: {'chapterId': chapterId}, options: _authOptions(auth), ); return response.data as Map; } /// GET /api/Reader/get-progress — get reading progress for a chapter. Future?> getProgress( Server server, { required int chapterId, }) async { final auth = await getAuth(server); try { final response = await _dio.get( '${server.url}/api/Reader/get-progress', queryParameters: {'chapterId': chapterId}, options: _authOptions(auth), ); return response.data as Map?; } on DioException catch (e) { if (e.response?.statusCode == 404) return null; rethrow; } } /// POST /api/Reader/progress — save reading progress. Future saveProgress( Server server, { required int chapterId, required int volumeId, required int seriesId, required int libraryId, required int pageNum, String? bookScrollId, }) async { final auth = await getAuth(server); await _dio.post( '${server.url}/api/Reader/progress', data: { 'chapterId': chapterId, 'volumeId': volumeId, 'seriesId': seriesId, 'libraryId': libraryId, 'pageNum': pageNum, if (bookScrollId != null) 'bookScrollId': bookScrollId, }, options: _authOptions(auth), ); } /// POST /api/Reader/mark-volume-read Future markVolumeRead( Server server, { required int seriesId, required int volumeId, }) async { final auth = await getAuth(server); await _dio.post( '${server.url}/api/Reader/mark-volume-read', data: { 'seriesId': seriesId, 'volumeId': volumeId, }, options: _authOptions(auth), ); } /// POST /api/Reader/mark-volume-unread Future markVolumeUnread( Server server, { required int seriesId, required int volumeId, }) async { final auth = await getAuth(server); await _dio.post( '${server.url}/api/Reader/mark-volume-unread', data: { 'seriesId': seriesId, 'volumeId': volumeId, }, options: _authOptions(auth), ); } // --------------------------------------------------------------------------- // Image URLs (constructed, not fetched — apiKey in query param) // --------------------------------------------------------------------------- /// Build a series cover URL. String seriesCoverUrl(String baseUrl, String apiKey, int seriesId) => '$baseUrl/api/Image/series-cover?seriesId=$seriesId&apiKey=$apiKey'; /// Build a volume cover URL. String volumeCoverUrl(String baseUrl, String apiKey, int volumeId) => '$baseUrl/api/Image/volume-cover?volumeId=$volumeId&apiKey=$apiKey'; /// Build a chapter cover URL. String chapterCoverUrl(String baseUrl, String apiKey, int chapterId) => '$baseUrl/api/Image/chapter-cover?chapterId=$chapterId&apiKey=$apiKey'; /// Build a page image URL template (with {pageNumber} placeholder). String pageImageUrlTemplate(String baseUrl, String apiKey, int chapterId) => '$baseUrl/api/Reader/image?chapterId=$chapterId&page={pageNumber}&apiKey=$apiKey'; // --------------------------------------------------------------------------- // Downloads // --------------------------------------------------------------------------- /// Download a chapter file. Returns the downloaded file. Future downloadChapter( Server server, { required int chapterId, required String savePath, void Function(int received, int total)? onProgress, CancelToken? cancelToken, }) async { final auth = await getAuth(server); await _dio.download( '${server.url}/api/Download/chapter', savePath, queryParameters: {'chapterId': chapterId}, options: Options(headers: {'Authorization': 'Bearer ${auth.token}'}), onReceiveProgress: onProgress, cancelToken: cancelToken, ); } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- Options _authOptions(KavitaAuth auth) => Options(headers: {'Authorization': 'Bearer ${auth.token}'}); } /// Exception for Kavita API errors. class KavitaApiException implements Exception { final String message; final int? statusCode; KavitaApiException(this.message, {this.statusCode}); @override String toString() => 'KavitaApiException: $message'; }