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
284 lines
8.8 KiB
Dart
284 lines
8.8 KiB
Dart
import 'package:worldhopper/models/enhanced_metadata.dart';
|
|
import 'package:worldhopper/models/entry.dart';
|
|
import 'package:worldhopper/models/feed.dart';
|
|
import 'package:worldhopper/models/link.dart';
|
|
import 'package:worldhopper/models/stream_link.dart';
|
|
import 'package:worldhopper/services/kavita_api_client.dart';
|
|
import 'package:worldhopper/services/opds_parser.dart' show ParsedFeed;
|
|
|
|
/// Maps Kavita REST API JSON responses to [Feed]/[Entry] models.
|
|
///
|
|
/// This is a pure data-transformation layer — no HTTP calls. It produces
|
|
/// the same model objects that the OPDS parser would, so the UI layer
|
|
/// works identically for both server types.
|
|
class KavitaFeedMapper {
|
|
final String baseUrl;
|
|
final String apiKey;
|
|
final KavitaApiClient _apiClient;
|
|
|
|
KavitaFeedMapper({
|
|
required this.baseUrl,
|
|
required this.apiKey,
|
|
required KavitaApiClient apiClient,
|
|
}) : _apiClient = apiClient;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Root → Libraries
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Convert a list of LibraryDto → navigation [Feed].
|
|
Feed mapLibrariesToFeed(List<Map<String, dynamic>> libraries) {
|
|
final entries = libraries.map(_libraryToEntry).toList();
|
|
return Feed(
|
|
id: 'kavita-root',
|
|
title: 'Libraries',
|
|
entries: entries,
|
|
);
|
|
}
|
|
|
|
Entry _libraryToEntry(Map<String, dynamic> lib) {
|
|
final id = lib['id'] as int;
|
|
final name = lib['name'] as String? ?? 'Library';
|
|
|
|
return Entry(
|
|
id: 'library-$id',
|
|
title: name,
|
|
links: [
|
|
Link(rel: 'subsection', href: 'kavita://libraries/$id'),
|
|
],
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Library → Series
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Convert a paginated series list → navigation [Feed].
|
|
Feed mapSeriesToFeed(
|
|
List<Map<String, dynamic>> series, {
|
|
required int libraryId,
|
|
int page = 1,
|
|
int pageSize = 20,
|
|
int? totalItems,
|
|
}) {
|
|
final entries = series.map(_seriesToEntry).toList();
|
|
|
|
final links = <Link>[];
|
|
// Pagination: add next link if there are more items
|
|
if (totalItems != null && page * pageSize < totalItems) {
|
|
links.add(Link(
|
|
rel: 'next',
|
|
href: 'kavita://libraries/$libraryId?page=${page + 1}',
|
|
));
|
|
}
|
|
|
|
return Feed(
|
|
id: 'kavita-library-$libraryId',
|
|
title: 'Series',
|
|
entries: entries,
|
|
links: links,
|
|
totalResults: totalItems,
|
|
itemsPerPage: pageSize,
|
|
startIndex: (page - 1) * pageSize,
|
|
);
|
|
}
|
|
|
|
Entry _seriesToEntry(Map<String, dynamic> series) {
|
|
final id = series['id'] as int;
|
|
final name = series['name'] as String? ?? 'Untitled';
|
|
// Build cover URL
|
|
final coverUrl = _apiClient.seriesCoverUrl(baseUrl, apiKey, id);
|
|
|
|
return Entry(
|
|
id: 'series-$id',
|
|
title: name,
|
|
coverUrl: coverUrl,
|
|
thumbnailUrl: coverUrl,
|
|
links: [
|
|
Link(rel: 'subsection', href: 'kavita://series/$id'),
|
|
],
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Series → Volumes/Chapters (acquisition feed)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Convert series-detail → acquisition [ParsedFeed].
|
|
///
|
|
/// Returns both the [Feed] and a metadata map for the publication cache.
|
|
ParsedFeed mapSeriesDetailToFeed(
|
|
Map<String, dynamic> detail, {
|
|
required int seriesId,
|
|
Map<String, dynamic>? seriesInfo,
|
|
}) {
|
|
final volumes =
|
|
(detail['volumes'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
|
final specials =
|
|
(detail['specials'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
|
|
|
final entries = <Entry>[];
|
|
final metadata = <String, EnhancedMetadata>{};
|
|
|
|
// Extract series name from seriesInfo if available
|
|
final seriesName = seriesInfo?['name'] as String?;
|
|
|
|
for (final volume in volumes) {
|
|
final chapters =
|
|
(volume['chapters'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
|
|
|
if (chapters.length == 1) {
|
|
// Single chapter in volume — show as single entry
|
|
final chapter = chapters.first;
|
|
final entry = _chapterToEntry(chapter, seriesId, volume: volume);
|
|
entries.add(entry);
|
|
if (seriesName != null) {
|
|
metadata[entry.id] = EnhancedMetadata(series: seriesName);
|
|
}
|
|
} else {
|
|
// Multiple chapters — show each chapter as an entry
|
|
for (final chapter in chapters) {
|
|
final entry = _chapterToEntry(chapter, seriesId, volume: volume);
|
|
entries.add(entry);
|
|
if (seriesName != null) {
|
|
metadata[entry.id] = EnhancedMetadata(series: seriesName);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add specials
|
|
for (final special in specials) {
|
|
final entry = _chapterToEntry(special, seriesId);
|
|
entries.add(entry);
|
|
if (seriesName != null) {
|
|
metadata[entry.id] = EnhancedMetadata(series: seriesName);
|
|
}
|
|
}
|
|
|
|
final feed = Feed(
|
|
id: 'kavita-series-$seriesId',
|
|
title: seriesName ?? 'Series',
|
|
entries: entries,
|
|
);
|
|
|
|
return ParsedFeed(feed, metadata);
|
|
}
|
|
|
|
Entry _chapterToEntry(
|
|
Map<String, dynamic> chapter,
|
|
int seriesId, {
|
|
Map<String, dynamic>? volume,
|
|
}) {
|
|
final chapterId = chapter['id'] as int;
|
|
final pages = chapter['pages'] as int? ?? 0;
|
|
final pagesRead = chapter['pagesRead'] as int? ?? 0;
|
|
final volumeId = volume?['id'] as int? ?? chapter['volumeId'] as int? ?? 0;
|
|
// Build title from volume/chapter info
|
|
final title = _buildChapterTitle(chapter, volume: volume);
|
|
|
|
// Determine content type from format
|
|
// MangaFormat: 0=Image, 1=Archive, 2=Unknown, 3=Epub, 4=Pdf
|
|
final format = chapter['format'] as int?;
|
|
final isEpub = format == 3;
|
|
|
|
// Build cover URL
|
|
final coverUrl = _apiClient.chapterCoverUrl(baseUrl, apiKey, chapterId);
|
|
|
|
// Build links — embed seriesId/volumeId/chapterId in the download URL
|
|
// so markAsRead can extract them later.
|
|
final links = <Link>[];
|
|
if (isEpub) {
|
|
links.add(Link(
|
|
rel: 'http://opds-spec.org/acquisition/open-access',
|
|
href:
|
|
'$baseUrl/api/Download/chapter?chapterId=$chapterId&seriesId=$seriesId&volumeId=$volumeId',
|
|
type: 'application/epub+zip',
|
|
));
|
|
} else {
|
|
links.add(Link(
|
|
rel: 'http://opds-spec.org/acquisition/open-access',
|
|
href:
|
|
'$baseUrl/api/Download/chapter?chapterId=$chapterId&seriesId=$seriesId&volumeId=$volumeId',
|
|
type: 'application/zip',
|
|
));
|
|
}
|
|
|
|
// Build StreamLink for non-EPUB (image-based reading)
|
|
StreamLink? streamLink;
|
|
if (!isEpub && pages > 0) {
|
|
streamLink = StreamLink(
|
|
href: _apiClient.pageImageUrlTemplate(baseUrl, apiKey, chapterId),
|
|
type: 'image/jpeg',
|
|
pageCount: pages,
|
|
lastRead: pagesRead > 0 ? pagesRead - 1 : null, // Convert to 0-indexed
|
|
);
|
|
}
|
|
|
|
// Authors from chapter writers
|
|
final writers = (chapter['writers'] as List?)
|
|
?.map((w) => (w as Map<String, dynamic>)['name'] as String? ?? '')
|
|
.where((n) => n.isNotEmpty)
|
|
.toList() ??
|
|
[];
|
|
|
|
// Summary
|
|
final summary = chapter['summary'] as String?;
|
|
|
|
return Entry(
|
|
id: 'chapter-$chapterId',
|
|
title: title,
|
|
summary: summary != null && summary.isNotEmpty ? summary : null,
|
|
authors: writers,
|
|
links: links,
|
|
streamLink: streamLink,
|
|
coverUrl: coverUrl,
|
|
thumbnailUrl: coverUrl,
|
|
);
|
|
}
|
|
|
|
String _buildChapterTitle(
|
|
Map<String, dynamic> chapter, {
|
|
Map<String, dynamic>? volume,
|
|
}) {
|
|
final isSpecial = chapter['isSpecial'] as bool? ?? false;
|
|
final titleName = chapter['titleName'] as String?;
|
|
final chapterNumber = chapter['number'] as String?;
|
|
final chapterRange = chapter['range'] as String?;
|
|
|
|
if (isSpecial && titleName != null && titleName.isNotEmpty) {
|
|
return titleName;
|
|
}
|
|
|
|
// If volume is provided, include volume info
|
|
if (volume != null) {
|
|
final volumeName = volume['name'] as String?;
|
|
final minNumber = volume['minNumber'] as num?;
|
|
|
|
// If the volume has a meaningful number and the chapter is the only one
|
|
if (minNumber != null && minNumber > 0) {
|
|
if (titleName != null && titleName.isNotEmpty) {
|
|
return 'Vol. ${minNumber.toInt()} - $titleName';
|
|
}
|
|
return 'Volume ${minNumber.toInt()}';
|
|
}
|
|
|
|
if (volumeName != null && volumeName.isNotEmpty) {
|
|
return volumeName;
|
|
}
|
|
}
|
|
|
|
// Fall back to chapter info
|
|
if (titleName != null && titleName.isNotEmpty) {
|
|
return titleName;
|
|
}
|
|
if (chapterRange != null && chapterRange.isNotEmpty) {
|
|
return 'Chapter $chapterRange';
|
|
}
|
|
if (chapterNumber != null && chapterNumber != '0') {
|
|
return 'Chapter $chapterNumber';
|
|
}
|
|
|
|
return 'Chapter';
|
|
}
|
|
}
|