import 'dart:io'; import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as path; import 'package:worldhopper/models/entry.dart'; import 'package:worldhopper/models/server.dart'; import 'package:worldhopper/models/stream_link.dart'; import 'package:worldhopper/services/download_storage_service.dart'; import 'package:worldhopper/services/kavita_api_client.dart'; import 'package:worldhopper/services/kavita_feed_mapper.dart'; import 'package:worldhopper/services/opds_parser.dart' show ParsedFeed; import 'package:worldhopper/services/opds_service.dart' show ServerException; import 'package:worldhopper/services/server_software/server_software.dart'; /// Kavita server implementation using the Kavita REST API directly. /// /// Users configure a base URL (e.g., `https://kavita.example.com`) and /// credentials. All browsing, reading, and downloads go through the /// Kavita REST API — not OPDS. class KavitaServerSoftware implements ServerSoftware { final KavitaApiClient _api; final DownloadStorageService _storageService; KavitaServerSoftware({ KavitaApiClient? apiClient, DownloadStorageService? storageService, }) : _api = apiClient ?? KavitaApiClient(), _storageService = storageService ?? DownloadStorageService(); @override bool detectFromUrl(Server server) { // Kavita base URLs don't contain /api/opds/ — that's the old OPDS path. // We detect Kavita if the user selects it from the dropdown; URL-based // auto-detection is not reliable for plain base URLs. return false; } // --------------------------------------------------------------------------- // Connection // --------------------------------------------------------------------------- @override Future testConnection(Server server, {CancelToken? cancelToken}) async { await _api.getAuth(server); return true; } // --------------------------------------------------------------------------- // Feed browsing // --------------------------------------------------------------------------- @override Future fetchRootFeed(Server server, {CancelToken? cancelToken}) async { final auth = await _api.getAuth(server); final libraries = await _api.getLibraries(server); final mapper = _createMapper(server.url, auth.apiKey); return ParsedFeed(mapper.mapLibrariesToFeed(libraries), {}); } @override Future fetchFeed(Server server, String url, {CancelToken? cancelToken}) async { final uri = Uri.parse(url); // Handle kavita:// synthetic URLs if (uri.scheme == 'kavita') { return _handleSyntheticUrl(server, uri); } // For the root feed URL (server.url itself), return the root feed if (url == server.url) { return fetchRootFeed(server, cancelToken: cancelToken); } throw ServerException('Unknown Kavita feed URL: $url'); } Future _handleSyntheticUrl(Server server, Uri uri) async { final auth = await _api.getAuth(server); final mapper = _createMapper(server.url, auth.apiKey); if (uri.host == 'libraries' && uri.pathSegments.isNotEmpty) { final libraryId = int.parse(uri.pathSegments.first); final page = int.tryParse(uri.queryParameters['page'] ?? '') ?? 1; final result = await _api.getSeries( server, libraryId: libraryId, page: page, ); return ParsedFeed( mapper.mapSeriesToFeed( result.items, libraryId: libraryId, page: page, totalItems: result.totalItems, ), {}, ); } if (uri.host == 'series' && uri.pathSegments.isNotEmpty) { final seriesId = int.parse(uri.pathSegments.first); final detail = await _api.getSeriesDetail(server, seriesId: seriesId); // Fetch series info for the name Map? seriesInfo; try { seriesInfo = await _api.getSeriesInfo(server, seriesId: seriesId); } catch (_) { // Non-critical — we'll use a fallback title } return mapper.mapSeriesDetailToFeed( detail, seriesId: seriesId, seriesInfo: seriesInfo, ); } throw ServerException('Unknown Kavita synthetic URL: $uri'); } // --------------------------------------------------------------------------- // Reading progress // --------------------------------------------------------------------------- @override Future> fetchEntryProgress( Server server, List entries, ) async { final result = {}; // For Kavita, progress is embedded directly on the Entry (populated by the // feed mapper from pagesRead/pages in the series-detail response). Works // for both image-stream chapters and EPUB books. for (final entry in entries) { final pagesRead = entry.pagesRead; final totalPages = entry.totalPages; if (pagesRead != null && totalPages != null && pagesRead > 0 && totalPages > 0) { result[entry.id] = EntryProgress( entryId: entry.id, currentPage: pagesRead, totalPages: totalPages, ); } } return result; } @override Future reportPageProgress( Server server, StreamLink streamLink, int page) async { // Extract chapterId from the streamLink href final chapterId = _extractChapterIdFromUrl(streamLink.href); if (chapterId == null) { debugPrint( 'Kavita.reportPageProgress: could not extract chapterId from ${streamLink.href}'); return; } try { // We need seriesId, volumeId, libraryId for the progress endpoint. // Get them from chapter-info. final chapterInfo = await _api.getChapterInfo(server, chapterId: chapterId); await _api.saveProgress( server, chapterId: chapterId, volumeId: chapterInfo['volumeId'] as int, seriesId: chapterInfo['seriesId'] as int, libraryId: chapterInfo['libraryId'] as int, pageNum: page, ); } catch (e) { debugPrint('Kavita.reportPageProgress: failed: $e'); } } @override Future reportEpubProgress( Server server, Entry entry, double progress, ) async { final chapterId = _extractChapterIdFromEntry(entry); if (chapterId == null) { debugPrint( 'Kavita.reportEpubProgress: no chapterId for entry ${entry.id}'); return; } try { final info = await _api.getChapterInfo(server, chapterId: chapterId); final totalPages = (info['pages'] as int?) ?? 0; // Kavita expects an integer pageNum. When we know the chapter's page // count (Kavita's own synthetic pagination), scale the percentage to // it; otherwise fall back to a 0–100 scale so the server still receives // a monotonic, bounded value. final pageNum = totalPages > 0 ? (progress * totalPages).round().clamp(0, totalPages) : (progress * 100).round().clamp(0, 100); await _api.saveProgress( server, chapterId: chapterId, volumeId: info['volumeId'] as int, seriesId: info['seriesId'] as int, libraryId: info['libraryId'] as int, pageNum: pageNum, ); } catch (e) { debugPrint('Kavita.reportEpubProgress: failed: $e'); } } // --------------------------------------------------------------------------- // Mark read/unread // --------------------------------------------------------------------------- @override Future markAsRead(Server server, Entry entry) async { final ids = _extractIds(entry); if (ids == null) { debugPrint( 'Kavita.markAsRead: could not extract IDs from entry ${entry.id}'); return; } try { await _api.markVolumeRead( server, seriesId: ids.seriesId, volumeId: ids.volumeId, ); debugPrint( 'Kavita.markAsRead: marked volume ${ids.volumeId} (series ${ids.seriesId})'); } catch (e) { debugPrint('Kavita.markAsRead: failed: $e'); } } @override Future markAsUnread(Server server, Entry entry) async { final ids = _extractIds(entry); if (ids == null) { debugPrint( 'Kavita.markAsUnread: could not extract IDs from entry ${entry.id}'); return; } try { await _api.markVolumeUnread( server, seriesId: ids.seriesId, volumeId: ids.volumeId, ); debugPrint( 'Kavita.markAsUnread: marked volume ${ids.volumeId} (series ${ids.seriesId}) as unread'); } catch (e) { debugPrint('Kavita.markAsUnread: failed: $e'); } } // --------------------------------------------------------------------------- // Downloads // --------------------------------------------------------------------------- @override Future downloadEpub( Server server, Entry entry, { String? savePath, void Function(double progress)? onProgress, CancelToken? cancelToken, }) async { final chapterId = _extractChapterIdFromEntry(entry); if (chapterId == null) { throw Exception('Cannot extract chapterId from entry ${entry.id}'); } final filePath = savePath ?? path.join((await getTemporaryDirectory()).path, '${entry.id}.epub'); await _api.downloadChapter( server, chapterId: chapterId, savePath: filePath, cancelToken: cancelToken, onProgress: (received, total) { if (total != -1 && onProgress != null) { onProgress(received / total); } }, ); return File(filePath); } @override Future downloadImageStreamChapter({ required Server server, required StreamLink streamLink, required String seriesId, required String chapterId, void Function(int downloaded, int total)? onProgress, CancelToken? cancelToken, }) async { final auth = await _api.getAuth(server); final chapterDir = await _storageService.getChapterDir(seriesId, chapterId); final totalPages = streamLink.pageCount; // Create an authenticated Dio for image downloads final dio = Dio(BaseOptions( headers: {'Authorization': 'Bearer ${auth.token}'}, connectTimeout: const Duration(seconds: 30), receiveTimeout: const Duration(seconds: 30), )); for (var i = 0; i < totalPages; i++) { if (cancelToken?.isCancelled == true) { throw DioException( requestOptions: RequestOptions(path: ''), type: DioExceptionType.cancel, ); } final pageUrl = streamLink.getPageUrl(i); final filePath = path.join(chapterDir.path, '$i.jpg'); // Skip if already downloaded if (await File(filePath).exists()) { onProgress?.call(i + 1, totalPages); continue; } await dio.download(pageUrl, filePath, cancelToken: cancelToken); onProgress?.call(i + 1, totalPages); } return chapterDir.path; } // --------------------------------------------------------------------------- // Auth & cache // --------------------------------------------------------------------------- @override Map getAuthHeaders(Server server) { // Kavita image URLs include apiKey as query param, no extra headers needed return {}; } @override void clearCachedState(String serverId) { _api.clearAuth(serverId); } // --------------------------------------------------------------------------- // Private helpers // --------------------------------------------------------------------------- KavitaFeedMapper _createMapper(String baseUrl, String apiKey) => KavitaFeedMapper(baseUrl: baseUrl, apiKey: apiKey, apiClient: _api); /// Extract chapterId from a page image URL or download URL. int? _extractChapterIdFromUrl(String url) { final uri = Uri.tryParse(url); if (uri == null) return null; final param = uri.queryParameters['chapterId']; if (param != null) return int.tryParse(param); return null; } /// Extract chapterId from an Entry's ID (format: "chapter-{id}"). int? _extractChapterIdFromEntry(Entry entry) { if (entry.id.startsWith('chapter-')) { return int.tryParse(entry.id.substring('chapter-'.length)); } // Try from acquisition link URL final link = entry.acquisitionLink; if (link != null) { return _extractChapterIdFromUrl(link.href); } return null; } /// Extract seriesId and volumeId for mark-as-read operations. /// /// The feed mapper embeds these as query params in the acquisition link URL. _KavitaIds? _extractIds(Entry entry) { // Try extracting from link URLs (query params embedded by KavitaFeedMapper) for (final link in entry.links) { final uri = Uri.tryParse(link.href); if (uri == null) continue; final seriesIdStr = uri.queryParameters['seriesId']; final volumeIdStr = uri.queryParameters['volumeId']; if (seriesIdStr != null && volumeIdStr != null) { final seriesId = int.tryParse(seriesIdStr); final volumeId = int.tryParse(volumeIdStr); if (seriesId != null && volumeId != null) { return _KavitaIds(seriesId: seriesId, volumeId: volumeId); } } } return null; } } class _KavitaIds { final int seriesId; final int volumeId; const _KavitaIds({required this.seriesId, required this.volumeId}); }