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

346 lines
11 KiB
Dart

import 'package:xml/xml.dart';
import 'package:worldhopper/config/constants.dart';
import 'package:worldhopper/models/enhanced_metadata.dart';
import 'package:worldhopper/models/feed.dart';
import 'package:worldhopper/models/entry.dart';
import 'package:worldhopper/models/link.dart';
import 'package:worldhopper/models/stream_link.dart';
import 'package:worldhopper/models/server.dart';
/// Container for parsed feed and associated metadata
class ParsedFeed {
final Feed feed;
final Map<String, EnhancedMetadata> entryMetadata;
const ParsedFeed(this.feed, this.entryMetadata);
}
/// Parser for OPDS 1.2 feeds with PSE (Page Streaming Extension) support
class OPDSParser {
/// Parse an OPDS feed from XML string
ParsedFeed parseFeed(String xmlContent, {Server? server}) {
final document = XmlDocument.parse(xmlContent);
final feedElement = document.findElements('feed').first;
// Parse feed metadata
final id = _getElementText(feedElement, 'id') ?? '';
final title = _getElementText(feedElement, 'title') ?? 'Untitled';
final subtitle = _getElementText(feedElement, 'subtitle');
final icon = _getElementText(feedElement, 'icon');
final updated = _parseDate(_getElementText(feedElement, 'updated'));
// Parse author
final authorElement = feedElement.findElements('author').firstOrNull;
final author =
authorElement != null ? _getElementText(authorElement, 'name') : null;
// Parse feed links
final feedLinks = feedElement
.findElements('link')
.map((element) => _parseLink(element, server: server))
.where((link) => link != null)
.cast<Link>()
.toList();
// Parse entries with metadata
final entriesWithMetadata = feedElement
.findElements('entry')
.map((element) => _parseEntry(element, server))
.toList();
final entries = entriesWithMetadata.map((tuple) => tuple.$1).toList();
final entryMetadata = Map.fromEntries(
entriesWithMetadata.map((tuple) => MapEntry(tuple.$1.id, tuple.$2)),
);
// Parse OpenSearch elements (for pagination)
int? totalResults;
int? itemsPerPage;
int? startIndex;
try {
final totalResultsText =
feedElement.findElements('totalResults').firstOrNull?.innerText;
if (totalResultsText != null) {
totalResults = int.tryParse(totalResultsText);
}
final itemsPerPageText =
feedElement.findElements('itemsPerPage').firstOrNull?.innerText;
if (itemsPerPageText != null) {
itemsPerPage = int.tryParse(itemsPerPageText);
}
final startIndexText =
feedElement.findElements('startIndex').firstOrNull?.innerText;
if (startIndexText != null) {
startIndex = int.tryParse(startIndexText);
}
} catch (e) {
// Ignore pagination parsing errors
}
final feed = Feed(
id: id,
title: title,
subtitle: subtitle,
icon: icon,
updated: updated,
author: author,
links: feedLinks,
entries: entries,
totalResults: totalResults,
itemsPerPage: itemsPerPage,
startIndex: startIndex,
);
return ParsedFeed(feed, entryMetadata);
}
/// Parse an entry element with enhanced metadata
(Entry, EnhancedMetadata) _parseEntry(XmlElement element, Server? server) {
final id = _getElementText(element, 'id') ?? '';
final title = _getElementText(element, 'title') ?? 'Untitled';
final summary = _getElementText(element, 'summary');
final content = _getElementText(element, 'content');
final published = _parseDate(_getElementText(element, 'published'));
final updated = _parseDate(_getElementText(element, 'updated'));
// Parse authors
final authors = element
.findElements('author')
.map((author) => _getElementText(author, 'name'))
.where((name) => name != null)
.cast<String>()
.toList();
// Parse categories
final categories = element
.findElements('category')
.map((category) => category.getAttribute('term'))
.where((term) => term != null)
.cast<String>()
.toList();
// Parse links
final links = element
.findElements('link')
.map((linkElement) => _parseLink(linkElement, server: server))
.where((link) => link != null)
.cast<Link>()
.toList();
// Parse PSE stream link
final streamLink = _parseStreamLink(element, server);
// Extract cover and thumbnail URLs and resolve them
String? coverUrl;
String? thumbnailUrl;
for (final link in links) {
if (link.rel.contains('image') || link.rel.contains('cover')) {
final resolvedHref =
server != null ? server.resolveUrl(link.href) : link.href;
if (link.rel.contains('thumbnail')) {
thumbnailUrl = resolvedHref;
} else {
coverUrl = resolvedHref;
}
}
}
final entry = Entry(
id: id,
title: title,
summary: summary,
content: content,
published: published,
updated: updated,
authors: authors,
categories: categories,
links: links,
streamLink: streamLink,
coverUrl: coverUrl,
thumbnailUrl: thumbnailUrl,
);
final metadata = _parseDCTermsMetadata(element);
return (entry, metadata);
}
/// Parse a link element
Link? _parseLink(XmlElement element, {Server? server}) {
final rel = element.getAttribute('rel');
final href = element.getAttribute('href');
if (rel == null || href == null) return null;
// Resolve href against server URL if server is provided
// This ensures all links (acquisition, navigation, etc.) are absolute URLs
final resolvedHref = server != null ? server.resolveUrl(href) : href;
return Link(
rel: rel,
href: resolvedHref, // Now absolute URL
type: element.getAttribute('type'),
title: element.getAttribute('title'),
);
}
/// Parse a PSE stream link from an entry
StreamLink? _parseStreamLink(XmlElement entryElement, Server? server) {
try {
// Find link with rel="http://vaemendis.net/opds-pse/stream"
final linkElements = entryElement.findElements('link');
for (final linkElement in linkElements) {
final rel = linkElement.getAttribute('rel');
if (rel == AppConstants.opdsPseStreamRel) {
final href = linkElement.getAttribute('href');
final type = linkElement.getAttribute('type');
if (href == null || type == null) continue;
// Parse PSE namespace attributes
// Try different namespace prefixes
int? pageCount;
int? lastRead;
DateTime? lastReadDate;
// Try to find pse:count attribute
final countAttr = linkElement.getAttribute('count',
namespace: AppConstants.opdsPseNamespace) ??
linkElement.getAttribute('pse:count');
if (countAttr != null) {
pageCount = int.tryParse(countAttr);
}
// Try to find pse:lastRead attribute
final lastReadAttr = linkElement.getAttribute('lastRead',
namespace: AppConstants.opdsPseNamespace) ??
linkElement.getAttribute('pse:lastRead');
if (lastReadAttr != null) {
lastRead = int.tryParse(lastReadAttr);
}
// Try to find pse:lastReadDate attribute
final lastReadDateAttr = linkElement.getAttribute('lastReadDate',
namespace: AppConstants.opdsPseNamespace) ??
linkElement.getAttribute('pse:lastReadDate');
if (lastReadDateAttr != null) {
lastReadDate = _parseDate(lastReadDateAttr);
}
if (pageCount != null) {
// Resolve href against server URL if server is provided
// This matches the pattern used for cover/thumbnail URLs
final resolvedHref =
server != null ? server.resolveUrl(href) : href;
return StreamLink(
href: resolvedHref,
type: type,
pageCount: pageCount,
lastRead: lastRead,
lastReadDate: lastReadDate,
);
}
}
}
} catch (e) {
// Ignore PSE parsing errors
}
return null;
}
/// Get text content of a child element
String? _getElementText(XmlElement parent, String elementName) {
try {
final element = parent.findElements(elementName).firstOrNull;
return element?.innerText.trim();
} catch (e) {
return null;
}
}
/// Parse an Atom date string
DateTime? _parseDate(String? dateString) {
if (dateString == null || dateString.isEmpty) return null;
try {
return DateTime.parse(dateString);
} catch (e) {
return null;
}
}
/// Parse Dublin Core Terms metadata from entry element
EnhancedMetadata _parseDCTermsMetadata(XmlElement element) {
String? series;
double? seriesPosition;
String? publisher;
String? language;
String? isbn;
try {
// Try to find dcterms:isPartOf (series)
final isPartOfElement = element
.findElements('isPartOf', namespace: AppConstants.dctermsNamespace)
.firstOrNull;
if (isPartOfElement != null) {
series = isPartOfElement.innerText.trim();
}
// Try schema:position or dcterms:position for series position
final positionElement = element.findElements('position').firstOrNull;
if (positionElement != null) {
seriesPosition = double.tryParse(positionElement.innerText.trim());
}
// dcterms:publisher
final publisherElement = element
.findElements('publisher', namespace: AppConstants.dctermsNamespace)
.firstOrNull;
if (publisherElement != null) {
publisher = publisherElement.innerText.trim();
}
// dcterms:language
final languageElement = element
.findElements('language', namespace: AppConstants.dctermsNamespace)
.firstOrNull;
if (languageElement != null) {
language = languageElement.innerText.trim();
}
// dcterms:identifier - look for ISBN
final identifierElements = element.findElements('identifier',
namespace: AppConstants.dctermsNamespace);
for (final idElement in identifierElements) {
final text = idElement.innerText.trim().toUpperCase();
if (text.contains('ISBN')) {
// Extract ISBN: remove "ISBN:", "ISBN-13:", etc.
isbn = text.replaceAll(RegExp(r'ISBN[- ]?(10|13)?:?\s*'), '').trim();
break;
}
}
} catch (e) {
// Ignore dcterms parsing errors
}
return EnhancedMetadata(
series: series,
seriesPosition: seriesPosition,
publisher: publisher,
language: language,
isbn: isbn,
);
}
}