When downloading a book via OPDS, the last acquisition link is chosen when there are multiple. This allows choosing which format to download when there are many. Having only one format will automatically download that.
265 lines
8.8 KiB
C++
265 lines
8.8 KiB
C++
#include "OpdsParser.h"
|
|
|
|
#include <FsHelpers.h>
|
|
#include <Logging.h>
|
|
|
|
#include <cstring>
|
|
|
|
namespace {
|
|
// Returns the length of href after trimming trailing slashes.
|
|
size_t trimmedHrefLength(const char* href) {
|
|
size_t len = strlen(href);
|
|
while (len > 0 && href[len - 1] == '/') {
|
|
len--;
|
|
}
|
|
return len;
|
|
}
|
|
|
|
std::string_view trimmedHrefView(const char* href) { return std::string_view{href, trimmedHrefLength(href)}; }
|
|
|
|
// Returns an OpdsAcquisitionLink if the type and href correspond to a supported
|
|
// acquisition format, otherwise returns an empty OpdsAcquisitionLink.
|
|
OpdsAcquisitionLink supportedAcquisitionLink(const char* type, const char* href) {
|
|
if (!type || !href || type[0] == '\0' || href[0] == '\0') {
|
|
return {};
|
|
}
|
|
|
|
// Some OPDS feeds append a trailing slash to format URLs like
|
|
// `/opds/book/123/kepub/`. Trim it so suffix checks work on the final segment.
|
|
const std::string_view trimmedHref = trimmedHrefView(href);
|
|
|
|
if (strcmp(type, "application/epub+zip") == 0) {
|
|
if (FsHelpers::checkFileExtension(trimmedHref, ".kepub.epub")) {
|
|
return {href, type, "kepub", ".kepub.epub"};
|
|
}
|
|
|
|
// Calibre-Web-Automated uses trailing path segments like `/kepub/` instead of
|
|
// filename extensions, so match `/kepub` after trimming trailing slashes.
|
|
if (FsHelpers::checkFileExtension(trimmedHref, ".kepub") || FsHelpers::checkFileExtension(trimmedHref, "/kepub")) {
|
|
// Save bare KePub downloads with an `.epub` suffix so the existing ePub
|
|
// reader can open them.
|
|
return {href, type, "kepub", ".kepub.epub"};
|
|
}
|
|
return {href, type, "epub", ".epub"};
|
|
}
|
|
|
|
if (strcmp(type, "text/plain") == 0) {
|
|
return {href, type, "txt", ".txt"};
|
|
}
|
|
|
|
if (strcmp(type, "text/markdown") == 0 || strcmp(type, "text/x-markdown") == 0) {
|
|
return {href, type, "md", ".md"};
|
|
}
|
|
|
|
if (FsHelpers::checkFileExtension(trimmedHref, ".xtc")) {
|
|
return {href, "application/vnd.xteink.xtc", "xtc", ".xtc"};
|
|
}
|
|
|
|
if (FsHelpers::checkFileExtension(trimmedHref, ".xth") || FsHelpers::checkFileExtension(trimmedHref, ".xtch")) {
|
|
return {href, "application/vnd.xteink.xtch", "xtch", ".xtch"};
|
|
}
|
|
|
|
return {};
|
|
}
|
|
|
|
// Determine if the given OpdsEntry's acquisition link href is already present,
|
|
// to prevent duplicate download targets.
|
|
bool hasEquivalentAcquisitionLink(const OpdsEntry& entry, const OpdsAcquisitionLink& candidate) {
|
|
const std::string_view normalizedCandidateHref = trimmedHrefView(candidate.href.c_str());
|
|
for (const auto& link : entry.acquisitionLinks) {
|
|
if (trimmedHrefView(link.href.c_str()) == normalizedCandidateHref) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
} // namespace
|
|
|
|
OpdsParser::OpdsParser() {
|
|
parser = XML_ParserCreate(nullptr);
|
|
if (!parser) {
|
|
errorOccured = true;
|
|
LOG_DBG("OPDS", "Couldn't allocate memory for parser");
|
|
}
|
|
}
|
|
|
|
OpdsParser::~OpdsParser() {
|
|
if (parser) {
|
|
XML_StopParser(parser, XML_FALSE);
|
|
XML_SetElementHandler(parser, nullptr, nullptr);
|
|
XML_SetCharacterDataHandler(parser, nullptr);
|
|
XML_ParserFree(parser);
|
|
parser = nullptr;
|
|
}
|
|
}
|
|
|
|
size_t OpdsParser::write(uint8_t c) { return write(&c, 1); }
|
|
|
|
size_t OpdsParser::write(const uint8_t* xmlData, const size_t length) {
|
|
if (errorOccured) return length;
|
|
|
|
XML_SetUserData(parser, this);
|
|
XML_SetElementHandler(parser, startElement, endElement);
|
|
XML_SetCharacterDataHandler(parser, characterData);
|
|
|
|
const char* currentPos = reinterpret_cast<const char*>(xmlData);
|
|
size_t remaining = length;
|
|
constexpr size_t chunkSize = 1024;
|
|
|
|
while (remaining > 0) {
|
|
void* const buf = XML_GetBuffer(parser, chunkSize);
|
|
if (!buf) {
|
|
errorOccured = true;
|
|
XML_ParserFree(parser);
|
|
return length;
|
|
}
|
|
|
|
const size_t toRead = remaining < chunkSize ? remaining : chunkSize;
|
|
memcpy(buf, currentPos, toRead);
|
|
|
|
if (XML_ParseBuffer(parser, static_cast<int>(toRead), 0) == XML_STATUS_ERROR) {
|
|
errorOccured = true;
|
|
XML_ParserFree(parser);
|
|
return length;
|
|
}
|
|
currentPos += toRead;
|
|
remaining -= toRead;
|
|
}
|
|
return length;
|
|
}
|
|
|
|
void OpdsParser::flush() {
|
|
if (XML_Parse(parser, nullptr, 0, XML_TRUE) != XML_STATUS_OK) {
|
|
errorOccured = true;
|
|
XML_ParserFree(parser);
|
|
parser = nullptr;
|
|
}
|
|
}
|
|
|
|
bool OpdsParser::error() const { return errorOccured; }
|
|
|
|
void OpdsParser::clear() {
|
|
entries.clear();
|
|
searchTemplate.clear();
|
|
osdUrl.clear();
|
|
nextPageUrl.clear();
|
|
prevPageUrl.clear();
|
|
currentEntry = OpdsEntry{};
|
|
currentText.clear();
|
|
inEntry = inTitle = inAuthor = inAuthorName = inId = false;
|
|
}
|
|
|
|
std::vector<OpdsEntry> OpdsParser::getBooks() const {
|
|
std::vector<OpdsEntry> books;
|
|
for (const auto& entry : entries) {
|
|
if (entry.type == OpdsEntryType::BOOK) books.push_back(entry);
|
|
}
|
|
return books;
|
|
}
|
|
|
|
const char* OpdsParser::findAttribute(const XML_Char** atts, const char* name) {
|
|
for (int i = 0; atts[i]; i += 2) {
|
|
if (strcmp(atts[i], name) == 0) return atts[i + 1];
|
|
}
|
|
return nullptr;
|
|
}
|
|
|
|
void XMLCALL OpdsParser::startElement(void* userData, const XML_Char* name, const XML_Char** atts) {
|
|
auto* self = static_cast<OpdsParser*>(userData);
|
|
|
|
if (strcmp(name, "link") == 0 || strstr(name, ":link") != nullptr) {
|
|
const char* href = findAttribute(atts, "href");
|
|
if (href) {
|
|
const char* rel = findAttribute(atts, "rel");
|
|
const char* type = findAttribute(atts, "type");
|
|
|
|
if (rel && strcmp(rel, "search") == 0) {
|
|
std::string sHref(href);
|
|
if (sHref.find("{searchTerms}") != std::string::npos) {
|
|
self->searchTemplate = sHref;
|
|
} else if (type && strcmp(type, "application/opensearchdescription+xml") == 0) {
|
|
self->osdUrl = sHref;
|
|
}
|
|
} else if (rel && strcmp(rel, "next") == 0 && !self->inEntry) {
|
|
self->nextPageUrl = href;
|
|
} else if (rel && strcmp(rel, "previous") == 0 && !self->inEntry) {
|
|
self->prevPageUrl = href;
|
|
}
|
|
|
|
if (self->inEntry) {
|
|
if (rel && strstr(rel, "opds-spec.org/acquisition") != nullptr) {
|
|
const auto acquisition = supportedAcquisitionLink(type, href);
|
|
if (!acquisition.formatKey.empty() && !hasEquivalentAcquisitionLink(self->currentEntry, acquisition)) {
|
|
self->currentEntry.type = OpdsEntryType::BOOK;
|
|
if (self->currentEntry.acquisitionLinks.empty()) {
|
|
self->currentEntry.href = href;
|
|
} else if (self->currentEntry.acquisitionLinks.size() == 1 &&
|
|
self->currentEntry.acquisitionLinks.capacity() < 3) {
|
|
self->currentEntry.acquisitionLinks.reserve(3);
|
|
}
|
|
self->currentEntry.acquisitionLinks.push_back(acquisition);
|
|
}
|
|
} else if (type && strstr(type, "application/atom+xml") != nullptr) {
|
|
if (self->currentEntry.type != OpdsEntryType::BOOK) {
|
|
self->currentEntry.type = OpdsEntryType::NAVIGATION;
|
|
self->currentEntry.href = href;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (strcmp(name, "entry") == 0 || strstr(name, ":entry") != nullptr) {
|
|
self->inEntry = true;
|
|
self->currentEntry = OpdsEntry{};
|
|
return;
|
|
}
|
|
|
|
if (!self->inEntry) return;
|
|
|
|
if (strcmp(name, "title") == 0 || strstr(name, ":title") != nullptr) {
|
|
self->inTitle = true;
|
|
self->currentText.clear();
|
|
} else if (strcmp(name, "author") == 0 || strstr(name, ":author") != nullptr) {
|
|
self->inAuthor = true;
|
|
} else if (self->inAuthor && (strcmp(name, "name") == 0 || strstr(name, ":name") != nullptr)) {
|
|
self->inAuthorName = true;
|
|
self->currentText.clear();
|
|
} else if (strcmp(name, "id") == 0 || strstr(name, ":id") != nullptr) {
|
|
self->inId = true;
|
|
self->currentText.clear();
|
|
}
|
|
}
|
|
|
|
void XMLCALL OpdsParser::endElement(void* userData, const XML_Char* name) {
|
|
auto* self = static_cast<OpdsParser*>(userData);
|
|
|
|
if (strcmp(name, "entry") == 0 || strstr(name, ":entry") != nullptr) {
|
|
if (!self->currentEntry.title.empty() && !self->currentEntry.href.empty()) {
|
|
self->entries.push_back(self->currentEntry);
|
|
}
|
|
self->inEntry = false;
|
|
} else if (self->inEntry) {
|
|
if (strcmp(name, "title") == 0 || strstr(name, ":title") != nullptr) {
|
|
if (self->inTitle) self->currentEntry.title = self->currentText;
|
|
self->inTitle = false;
|
|
} else if (strcmp(name, "author") == 0 || strstr(name, ":author") != nullptr) {
|
|
self->inAuthor = false;
|
|
} else if (self->inAuthorName && (strcmp(name, "name") == 0 || strstr(name, ":name") != nullptr)) {
|
|
self->currentEntry.author = self->currentText;
|
|
self->inAuthorName = false;
|
|
} else if (strcmp(name, "id") == 0 || strstr(name, ":id") != nullptr) {
|
|
if (self->inId) self->currentEntry.id = self->currentText;
|
|
self->inId = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
void XMLCALL OpdsParser::characterData(void* userData, const XML_Char* s, const int len) {
|
|
auto* self = static_cast<OpdsParser*>(userData);
|
|
if (self->inTitle || self->inAuthorName || self->inId) {
|
|
self->currentText.append(s, len);
|
|
}
|
|
}
|