worldhopper/lib/services/kavita_feed_mapper.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

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';
}
}