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
359 lines
11 KiB
Dart
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';
|
|
}
|