418 lines
13 KiB
Dart
418 lines
13 KiB
Dart
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 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<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});
|
||
}
|