worldhopper/lib/services/kavita_api_client.dart
Felipe M. 58a562abe8
feat: refactor server software abstraction with Kavita REST API integration
Rename all OPDS-prefixed models to generic names (Server, Feed, Entry,
Link, StreamLink), expand the ServerSoftware interface to cover all
server interactions, and implement a full Kavita REST API client that
replaces the OPDS delegation.

- Rename OPDS* models to generic names across ~60 files
- Add database migrations 13 (opds_id → entry_id) and 14 (unify credentials)
- Create OPDSServerSoftware wrapping existing OPDS services
- Create KavitaApiClient for direct Kavita REST API calls
- Create KavitaFeedMapper to convert Kavita JSON to Feed/Entry models
- Rewrite KavitaServerSoftware to use native API (no OPDS delegation)
- Unify server credentials (remove softwareUsername/softwarePassword)
- Simplify add/edit server UI to single auth section
- Add test connection button to server add/edit screens
- Add progress indicator to PublicationCard using local and server data
- Eliminate softwareType branching in reader screens
- Add EntryProgress and fetchEntryProgress to ServerSoftware interface
- Fix Entry.acquisitionLink crash on empty links
- Add 59 new tests covering models, services, and providers
2026-04-06 17:46:48 +02:00

359 lines
11 KiB
Dart

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<Map<String, dynamic>> 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<String, KavitaAuth> _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<KavitaAuth> 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<String, dynamic>;
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<List<Map<String, dynamic>>> 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<Map<String, dynamic>>();
}
// ---------------------------------------------------------------------------
// Series
// ---------------------------------------------------------------------------
/// POST /api/Series/v2 — list series filtered by library, paginated.
Future<KavitaPaginatedResult> 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<Map<String, dynamic>>();
// 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<Map<String, dynamic>> 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<String, dynamic>;
}
/// GET /api/Series/{seriesId} — get series metadata.
Future<Map<String, dynamic>> 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<String, dynamic>;
}
// ---------------------------------------------------------------------------
// Reading
// ---------------------------------------------------------------------------
/// GET /api/Reader/chapter-info — get page count and metadata for a chapter.
Future<Map<String, dynamic>> 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<String, dynamic>;
}
/// GET /api/Reader/get-progress — get reading progress for a chapter.
Future<Map<String, dynamic>?> 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<String, dynamic>?;
} on DioException catch (e) {
if (e.response?.statusCode == 404) return null;
rethrow;
}
}
/// POST /api/Reader/progress — save reading progress.
Future<void> 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<void> 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<void> 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<void> 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';
}