worldhopper/lib/services/server_software/kavita_server_software.dart
Felipe M. b5debe2117
All checks were successful
Release / build (push) Successful in 9m40s
feat: sync EPUB reading progress to Kavita with debounced updates
2026-04-17 12:09:44 +02:00

418 lines
13 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 '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<bool> testConnection(Server server, {CancelToken? cancelToken}) async {
await _api.getAuth(server);
return true;
}
// ---------------------------------------------------------------------------
// Feed browsing
// ---------------------------------------------------------------------------
@override
Future<ParsedFeed> 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<ParsedFeed> 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<ParsedFeed> _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<String, dynamic>? 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<Map<String, EntryProgress>> fetchEntryProgress(
Server server,
List<Entry> entries,
) async {
final result = <String, EntryProgress>{};
// 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<void> 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<void> 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 0100 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<void> 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<void> 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<File> 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<String> 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<String, String> 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});
}