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> libraries) { final entries = libraries.map(_libraryToEntry).toList(); return Feed( id: 'kavita-root', title: 'Libraries', entries: entries, ); } Entry _libraryToEntry(Map 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> series, { required int libraryId, int page = 1, int pageSize = 20, int? totalItems, }) { final entries = series.map(_seriesToEntry).toList(); final links = []; // 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 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 detail, { required int seriesId, Map? seriesInfo, }) { final volumes = (detail['volumes'] as List?)?.cast>() ?? []; final specials = (detail['specials'] as List?)?.cast>() ?? []; final entries = []; final metadata = {}; // 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>() ?? []; 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 chapter, int seriesId, { Map? 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 = []; 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)['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 chapter, { Map? 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'; } }