feat(opds): Allow selecting the download format

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.
This commit is contained in:
Joel Goguen
2026-04-19 23:15:54 -04:00
parent c38013aac3
commit 1986092522
12 changed files with 941 additions and 14 deletions
+84 -4
View File
@@ -1,9 +1,81 @@
#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) {
@@ -116,10 +188,18 @@ void XMLCALL OpdsParser::startElement(void* userData, const XML_Char* name, cons
}
if (self->inEntry) {
if (rel && type && strstr(rel, "opds-spec.org/acquisition") != nullptr &&
strcmp(type, "application/epub+zip") == 0) {
self->currentEntry.type = OpdsEntryType::BOOK;
self->currentEntry.href = href;
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;
+8
View File
@@ -13,6 +13,13 @@ enum class OpdsEntryType {
BOOK // Downloadable book
};
struct OpdsAcquisitionLink {
std::string href;
std::string mimeType;
std::string formatKey;
std::string fileExtension;
};
/**
* Represents an entry from an OPDS feed (either a navigation link or a book).
*/
@@ -22,6 +29,7 @@ struct OpdsEntry {
std::string author; // Only for books
std::string href; // Navigation URL or epub download URL
std::string id;
std::vector<OpdsAcquisitionLink> acquisitionLinks;
};
// Legacy alias for backward compatibility