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 "OpdsParser.h"
#include <FsHelpers.h>
#include <Logging.h> #include <Logging.h>
#include <cstring> #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() { OpdsParser::OpdsParser() {
parser = XML_ParserCreate(nullptr); parser = XML_ParserCreate(nullptr);
if (!parser) { if (!parser) {
@@ -116,10 +188,18 @@ void XMLCALL OpdsParser::startElement(void* userData, const XML_Char* name, cons
} }
if (self->inEntry) { if (self->inEntry) {
if (rel && type && strstr(rel, "opds-spec.org/acquisition") != nullptr && if (rel && strstr(rel, "opds-spec.org/acquisition") != nullptr) {
strcmp(type, "application/epub+zip") == 0) { const auto acquisition = supportedAcquisitionLink(type, href);
self->currentEntry.type = OpdsEntryType::BOOK; if (!acquisition.formatKey.empty() && !hasEquivalentAcquisitionLink(self->currentEntry, acquisition)) {
self->currentEntry.href = href; 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) { } else if (type && strstr(type, "application/atom+xml") != nullptr) {
if (self->currentEntry.type != OpdsEntryType::BOOK) { if (self->currentEntry.type != OpdsEntryType::BOOK) {
self->currentEntry.type = OpdsEntryType::NAVIGATION; self->currentEntry.type = OpdsEntryType::NAVIGATION;
+8
View File
@@ -13,6 +13,13 @@ enum class OpdsEntryType {
BOOK // Downloadable book 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). * 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 author; // Only for books
std::string href; // Navigation URL or epub download URL std::string href; // Navigation URL or epub download URL
std::string id; std::string id;
std::vector<OpdsAcquisitionLink> acquisitionLinks;
}; };
// Legacy alias for backward compatibility // Legacy alias for backward compatibility
@@ -7,6 +7,7 @@
#include <Logging.h> #include <Logging.h>
#include <OpdsStream.h> #include <OpdsStream.h>
#include <WiFi.h> #include <WiFi.h>
#include <Xtc.h>
#include <expat.h> #include <expat.h>
#include <cctype> #include <cctype>
@@ -15,6 +16,7 @@
#include "CrossPointSettings.h" #include "CrossPointSettings.h"
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "OpdsFormatLabel.h"
#include "activities/network/WifiSelectionActivity.h" #include "activities/network/WifiSelectionActivity.h"
#include "activities/util/KeyboardEntryActivity.h" #include "activities/util/KeyboardEntryActivity.h"
#include "components/UITheme.h" #include "components/UITheme.h"
@@ -25,7 +27,20 @@
namespace { namespace {
constexpr int PAGE_ITEMS = 23; constexpr int PAGE_ITEMS = 23;
constexpr int FORMAT_ITEM_HEIGHT = 30;
constexpr int FORMAT_LIST_TOP_OFFSET = 20;
constexpr int FORMAT_LIST_BOTTOM_PADDING = 20;
int formatItemsPerPage(const Rect& contentRect) {
const int midY = contentRect.y + contentRect.height / 2;
const int listTop = midY + FORMAT_LIST_TOP_OFFSET;
const int listBottom = contentRect.y + contentRect.height - FORMAT_LIST_BOTTOM_PADDING;
const int rawAvailableHeight = listBottom - listTop;
const int availableHeight = rawAvailableHeight > FORMAT_ITEM_HEIGHT ? rawAvailableHeight : FORMAT_ITEM_HEIGHT;
const int itemsPerPage = availableHeight / FORMAT_ITEM_HEIGHT;
return itemsPerPage > 0 ? itemsPerPage : 1;
} }
} // namespace
void OpdsBookBrowserActivity::onEnter() { void OpdsBookBrowserActivity::onEnter() {
Activity::onEnter(); Activity::onEnter();
@@ -36,6 +51,9 @@ void OpdsBookBrowserActivity::onEnter() {
currentPath = ""; // Root path - user provides full URL in settings currentPath = ""; // Root path - user provides full URL in settings
searchTemplate.clear(); searchTemplate.clear();
selectorIndex = 0; selectorIndex = 0;
selectedBookIndex = -1;
formatSelectorIndex = 0;
formatSelectionLabels.clear();
consumeConfirm = false; consumeConfirm = false;
consumeBack = false; consumeBack = false;
errorMessage.clear(); errorMessage.clear();
@@ -52,6 +70,7 @@ void OpdsBookBrowserActivity::onExit() {
entries.clear(); entries.clear();
navigationHistory.clear(); navigationHistory.clear();
formatSelectionLabels.clear();
} }
void OpdsBookBrowserActivity::loop() { void OpdsBookBrowserActivity::loop() {
@@ -93,11 +112,51 @@ void OpdsBookBrowserActivity::loop() {
if (state == BrowserState::DOWNLOADING) return; if (state == BrowserState::DOWNLOADING) return;
if (state == BrowserState::FORMAT_SELECTION) {
if (selectedBookIndex < 0 || selectedBookIndex >= static_cast<int>(entries.size())) {
state = BrowserState::BROWSING;
requestUpdate();
return;
}
const auto& entry = entries[selectedBookIndex];
if (entry.acquisitionLinks.empty()) {
state = BrowserState::BROWSING;
selectedBookIndex = -1;
formatSelectionLabels.clear();
requestUpdate();
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
state = BrowserState::BROWSING;
selectedBookIndex = -1;
formatSelectionLabels.clear();
requestUpdate();
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
downloadBook(entry, entry.acquisitionLinks[formatSelectorIndex]);
return;
}
buttonNavigator.onNextRelease([this, &entry] {
formatSelectorIndex = ButtonNavigator::nextIndex(formatSelectorIndex, entry.acquisitionLinks.size());
requestUpdate();
});
buttonNavigator.onPreviousRelease([this, &entry] {
formatSelectorIndex = ButtonNavigator::previousIndex(formatSelectorIndex, entry.acquisitionLinks.size());
requestUpdate();
});
return;
}
if (state == BrowserState::BROWSING) { if (state == BrowserState::BROWSING) {
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (!entries.empty()) { if (!entries.empty()) {
const auto& entry = entries[selectorIndex]; const auto& entry = entries[selectorIndex];
entry.type == OpdsEntryType::BOOK ? downloadBook(entry) : navigateToEntry(entry); entry.type == OpdsEntryType::BOOK ? chooseBookFormat(entry) : navigateToEntry(entry);
} }
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { } else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
navigateBack(); navigateBack();
@@ -175,6 +234,34 @@ void OpdsBookBrowserActivity::render(RenderLock&&) {
return; return;
} }
if (state == BrowserState::FORMAT_SELECTION) {
const auto& entry = entries[selectedBookIndex];
auto title = renderer.truncatedText(UI_10_FONT_ID, entry.title.c_str(), contentRect.width - 40);
renderer.drawCenteredText(UI_10_FONT_ID, midY - 40, title.c_str(), true, EpdFontFamily::BOLD);
if (!entry.author.empty()) {
auto author = renderer.truncatedText(UI_10_FONT_ID, entry.author.c_str(), contentRect.width - 40);
renderer.drawCenteredText(UI_10_FONT_ID, midY - 10, author.c_str());
}
const int listTop = midY + 20;
const int itemsPerPage = formatItemsPerPage(contentRect);
const int pageStartIndex = formatSelectorIndex / itemsPerPage * itemsPerPage;
renderer.fillRect(contentRect.x, listTop + (formatSelectorIndex - pageStartIndex) * FORMAT_ITEM_HEIGHT - 2,
contentRect.width - 1, FORMAT_ITEM_HEIGHT);
for (int i = pageStartIndex;
i < static_cast<int>(entry.acquisitionLinks.size()) && i < pageStartIndex + itemsPerPage; i++) {
const char* label = i < static_cast<int>(formatSelectionLabels.size()) ? formatSelectionLabels[i].c_str() : "";
auto item = renderer.truncatedText(UI_10_FONT_ID, label, contentRect.width - 40);
renderer.drawText(UI_10_FONT_ID, contentRect.x + 20, listTop + (i - pageStartIndex) * FORMAT_ITEM_HEIGHT,
item.c_str(), i != formatSelectorIndex);
}
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_DOWNLOAD), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();
return;
}
// Browsing state // Browsing state
// Show appropriate button hint based on selected entry type // Show appropriate button hint based on selected entry type
const char* confirmLabel = const char* confirmLabel =
@@ -292,17 +379,43 @@ void OpdsBookBrowserActivity::navigateBack() {
} }
} }
void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) { // Opens a screen to allow the user to choose which format they want to download
// if multiple formats are available. If only one format is available, the
// download is started immediately.
void OpdsBookBrowserActivity::chooseBookFormat(const OpdsEntry& book) {
if (book.acquisitionLinks.empty()) {
state = BrowserState::ERROR;
errorMessage = tr(STR_DOWNLOAD_FAILED);
requestUpdate();
return;
}
if (book.acquisitionLinks.size() == 1) {
downloadBook(book, book.acquisitionLinks[0]);
return;
}
selectedBookIndex = selectorIndex;
formatSelectorIndex = 0;
formatSelectionLabels = buildOpdsFormatSelectionLabels(book.acquisitionLinks, SETTINGS.opdsServerUrl);
state = BrowserState::FORMAT_SELECTION;
requestUpdate();
}
// Downloads the selected book's acquisition link and saves it to the SD card.
void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book, const OpdsAcquisitionLink& acquisition) {
state = BrowserState::DOWNLOADING; state = BrowserState::DOWNLOADING;
statusMessage = book.title; statusMessage = book.title;
downloadProgress = 0; downloadProgress = 0;
downloadTotal = 0; downloadTotal = 0;
requestUpdate(true); requestUpdate(true);
std::string downloadUrl = std::string downloadUrl = (acquisition.href.rfind("http", 0) == 0)
(book.href.rfind("http", 0) == 0) ? book.href : UrlUtils::buildUrl(SETTINGS.opdsServerUrl, book.href); ? acquisition.href
std::string filename = : UrlUtils::buildUrl(SETTINGS.opdsServerUrl, acquisition.href);
"/" + StringUtils::sanitizeFilename(book.title + (book.author.empty() ? "" : " - " + book.author)) + ".epub"; std::string filename = "/" +
StringUtils::sanitizeFilename(book.title + (book.author.empty() ? "" : " - " + book.author)) +
acquisition.fileExtension;
LOG_DBG("OPDS", "Downloading: %s -> %s", downloadUrl.c_str(), filename.c_str()); LOG_DBG("OPDS", "Downloading: %s -> %s", downloadUrl.c_str(), filename.c_str());
@@ -330,10 +443,20 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) {
LOG_DBG("OPDS", "Download complete: %s", filename.c_str()); LOG_DBG("OPDS", "Download complete: %s", filename.c_str());
Epub(filename, "/.crosspoint").clearCache(); // Clear any existing cache for this book just in case it's a redownload of
// a previously opened book.
if (acquisition.mimeType == "application/epub+zip") {
Epub(filename, "/.crosspoint").clearCache();
} else if (acquisition.formatKey == "xtc" || acquisition.formatKey == "xtch") {
Xtc(filename, "/.crosspoint").clearCache();
}
selectedBookIndex = -1;
formatSelectionLabels.clear();
state = BrowserState::BROWSING; state = BrowserState::BROWSING;
requestUpdate(); requestUpdate();
} else { } else {
selectedBookIndex = -1;
formatSelectionLabels.clear();
state = BrowserState::ERROR; state = BrowserState::ERROR;
errorMessage = tr(STR_DOWNLOAD_FAILED); errorMessage = tr(STR_DOWNLOAD_FAILED);
requestUpdate(); requestUpdate();
@@ -1,7 +1,6 @@
#pragma once #pragma once
#include <OpdsParser.h> #include <OpdsParser.h>
#include <functional>
#include <string> #include <string>
#include <vector> #include <vector>
@@ -14,7 +13,16 @@
*/ */
class OpdsBookBrowserActivity final : public Activity { class OpdsBookBrowserActivity final : public Activity {
public: public:
enum class BrowserState { CHECK_WIFI, WIFI_SELECTION, LOADING, BROWSING, DOWNLOADING, ERROR, SEARCH_INPUT }; enum class BrowserState {
CHECK_WIFI,
WIFI_SELECTION,
LOADING,
BROWSING,
FORMAT_SELECTION,
DOWNLOADING,
ERROR,
SEARCH_INPUT
};
explicit OpdsBookBrowserActivity(GfxRenderer& renderer, MappedInputManager& mappedInput) explicit OpdsBookBrowserActivity(GfxRenderer& renderer, MappedInputManager& mappedInput)
: Activity("OpdsBookBrowser", renderer, mappedInput), buttonNavigator() {} : Activity("OpdsBookBrowser", renderer, mappedInput), buttonNavigator() {}
@@ -34,6 +42,9 @@ class OpdsBookBrowserActivity final : public Activity {
bool consumeConfirm = false; bool consumeConfirm = false;
bool consumeBack = false; // Added missing member bool consumeBack = false; // Added missing member
int selectorIndex = 0; int selectorIndex = 0;
int selectedBookIndex = -1;
int formatSelectorIndex = 0;
std::vector<std::string> formatSelectionLabels;
std::string errorMessage; std::string errorMessage;
std::string statusMessage; std::string statusMessage;
size_t downloadProgress = 0; size_t downloadProgress = 0;
@@ -45,7 +56,8 @@ class OpdsBookBrowserActivity final : public Activity {
void fetchFeed(const std::string& path); void fetchFeed(const std::string& path);
void navigateToEntry(const OpdsEntry& entry); void navigateToEntry(const OpdsEntry& entry);
void navigateBack(); void navigateBack();
void downloadBook(const OpdsEntry& book); void downloadBook(const OpdsEntry& book, const OpdsAcquisitionLink& acquisition);
void chooseBookFormat(const OpdsEntry& book);
void fetchOsdTemplate(const std::string& osdUrl); void fetchOsdTemplate(const std::string& osdUrl);
void launchSearch(); void launchSearch();
void performSearch(const std::string& query); void performSearch(const std::string& query);
+122
View File
@@ -0,0 +1,122 @@
#include "OpdsFormatLabel.h"
#include <algorithm>
#include <iterator>
#include "util/UrlUtils.h"
namespace {
bool hasDuplicateFormatKey(const OpdsAcquisitionLink& acquisition,
const std::vector<OpdsAcquisitionLink>& acquisitionLinks) {
size_t sameFormatCount = 0;
for (const auto& link : acquisitionLinks) {
if (link.formatKey == acquisition.formatKey) {
sameFormatCount++;
if (sameFormatCount > 1) {
return true;
}
}
}
return false;
}
std::string resolvedHostname(const OpdsAcquisitionLink& acquisition, const std::string& serverUrl) {
const std::string resolvedUrl =
acquisition.href.rfind("http", 0) == 0 ? acquisition.href : UrlUtils::buildUrl(serverUrl, acquisition.href);
return UrlUtils::extractHostname(resolvedUrl);
}
std::vector<std::string> resolvedHostnames(const std::vector<OpdsAcquisitionLink>& acquisitionLinks,
const std::string& serverUrl) {
std::vector<std::string> hostnames;
hostnames.reserve(acquisitionLinks.size());
std::transform(acquisitionLinks.begin(), acquisitionLinks.end(), std::back_inserter(hostnames),
[&serverUrl](const OpdsAcquisitionLink& link) { return resolvedHostname(link, serverUrl); });
return hostnames;
}
std::string buildDuplicateAwareLabel(const OpdsAcquisitionLink& acquisition,
const std::vector<OpdsAcquisitionLink>& acquisitionLinks,
const std::vector<std::string>& hostnames, const size_t currentIndex) {
std::string label = opdsBaseFormatLabel(acquisition);
if (!hasDuplicateFormatKey(acquisition, acquisitionLinks)) {
return label;
}
const std::string& hostname = hostnames[currentIndex];
if (hostname.empty()) {
return label;
}
label.reserve(label.size() + hostname.size() + 8);
label += " - ";
label += hostname;
size_t duplicateCount = 0;
size_t duplicateIndex = 0;
for (size_t i = 0; i < acquisitionLinks.size(); i++) {
if (acquisitionLinks[i].formatKey == acquisition.formatKey && hostnames[i] == hostname) {
duplicateCount++;
if (i <= currentIndex) {
duplicateIndex++;
}
}
}
if (duplicateCount > 1) {
label += " (";
label += std::to_string(duplicateIndex);
label += ")";
}
return label;
}
} // namespace
const char* opdsBaseFormatLabel(const OpdsAcquisitionLink& acquisition) {
if (acquisition.formatKey == "kepub") {
return "KEPUB";
}
if (acquisition.formatKey == "epub") {
return "EPUB";
}
if (acquisition.formatKey == "txt") {
return "TXT";
}
if (acquisition.formatKey == "md") {
return "MD";
}
if (acquisition.formatKey == "xtc") {
return "XTC";
}
if (acquisition.formatKey == "xtch") {
return "XTCH";
}
return "";
}
std::string opdsFormatSelectionLabel(const OpdsAcquisitionLink& acquisition,
const std::vector<OpdsAcquisitionLink>& acquisitionLinks,
const std::string& serverUrl) {
const auto hostnames = resolvedHostnames(acquisitionLinks, serverUrl);
for (size_t i = 0; i < acquisitionLinks.size(); i++) {
if (acquisitionLinks[i].href == acquisition.href && acquisitionLinks[i].formatKey == acquisition.formatKey) {
return buildDuplicateAwareLabel(acquisition, acquisitionLinks, hostnames, i);
}
}
return opdsBaseFormatLabel(acquisition);
}
std::vector<std::string> buildOpdsFormatSelectionLabels(const std::vector<OpdsAcquisitionLink>& acquisitionLinks,
const std::string& serverUrl) {
const auto hostnames = resolvedHostnames(acquisitionLinks, serverUrl);
std::vector<std::string> labels;
labels.reserve(acquisitionLinks.size());
for (size_t i = 0; i < acquisitionLinks.size(); i++) {
labels.push_back(buildDuplicateAwareLabel(acquisitionLinks[i], acquisitionLinks, hostnames, i));
}
return labels;
}
+13
View File
@@ -0,0 +1,13 @@
#pragma once
#include <OpdsParser.h>
#include <string>
#include <vector>
const char* opdsBaseFormatLabel(const OpdsAcquisitionLink& acquisition);
std::string opdsFormatSelectionLabel(const OpdsAcquisitionLink& acquisition,
const std::vector<OpdsAcquisitionLink>& acquisitionLinks,
const std::string& serverUrl);
std::vector<std::string> buildOpdsFormatSelectionLabels(const std::vector<OpdsAcquisitionLink>& acquisitionLinks,
const std::string& serverUrl);
@@ -0,0 +1,118 @@
#include <cstdio>
#include <string>
#include <vector>
#include "../../src/activities/browser/OpdsFormatLabel.h"
static int testsPassed = 0;
static int testsFailed = 0;
#define ASSERT_TRUE(cond) \
do { \
if (!(cond)) { \
fprintf(stderr, " FAIL: %s:%d: %s\n", __FILE__, __LINE__, #cond); \
testsFailed++; \
return; \
} \
} while (0)
#define ASSERT_EQ(a, b) \
do { \
if ((a) != (b)) { \
fprintf(stderr, " FAIL: %s:%d: %s != %s\n", __FILE__, __LINE__, #a, #b); \
testsFailed++; \
return; \
} \
} while (0)
#define PASS() testsPassed++
namespace {
OpdsAcquisitionLink makeLink(const char* href, const char* formatKey) {
return OpdsAcquisitionLink{href, "application/epub+zip", formatKey, ".epub"};
}
} // namespace
void testUniqueFormatUsesBaseLabel() {
printf("testUniqueFormatUsesBaseLabel...\n");
const auto link = makeLink("/books/example.epub", "epub");
const std::vector<OpdsAcquisitionLink> links{link};
ASSERT_EQ(opdsFormatSelectionLabel(link, links, "catalog.example.com"), "EPUB");
PASS();
}
void testDuplicateAbsoluteUrlsIncludeHostname() {
printf("testDuplicateAbsoluteUrlsIncludeHostname...\n");
const auto primary = makeLink("https://mirror-a.example.com/books/example.epub", "epub");
const auto secondary = makeLink("https://mirror-b.example.com/books/example.epub", "epub");
const std::vector<OpdsAcquisitionLink> links{primary, secondary};
ASSERT_EQ(opdsFormatSelectionLabel(primary, links, "catalog.example.com"), "EPUB - mirror-a.example.com");
ASSERT_EQ(opdsFormatSelectionLabel(secondary, links, "catalog.example.com"), "EPUB - mirror-b.example.com");
PASS();
}
void testDuplicateRootRelativeUrlsUseServerHostname() {
printf("testDuplicateRootRelativeUrlsUseServerHostname...\n");
const auto primary = makeLink("/opds/download/1/epub", "epub");
const auto secondary = makeLink("/opds/download/2/epub", "epub");
const std::vector<OpdsAcquisitionLink> links{primary, secondary};
ASSERT_EQ(opdsFormatSelectionLabel(primary, links, "https://catalog.example.com/opds"),
"EPUB - catalog.example.com (1)");
ASSERT_EQ(opdsFormatSelectionLabel(secondary, links, "https://catalog.example.com/opds"),
"EPUB - catalog.example.com (2)");
PASS();
}
void testDuplicateRelativeUrlsUseServerHostname() {
printf("testDuplicateRelativeUrlsUseServerHostname...\n");
const auto primary = makeLink("download/1.epub", "epub");
const auto secondary = makeLink("download/2.epub", "epub");
const std::vector<OpdsAcquisitionLink> links{primary, secondary};
ASSERT_EQ(opdsFormatSelectionLabel(primary, links, "catalog.example.com/opds"), "EPUB - catalog.example.com (1)");
ASSERT_EQ(opdsFormatSelectionLabel(secondary, links, "catalog.example.com/opds"), "EPUB - catalog.example.com (2)");
PASS();
}
void testDuplicateAbsoluteUrlsSameHostnameIncludeNumbering() {
printf("testDuplicateAbsoluteUrlsSameHostnameIncludeNumbering...\n");
const auto primary = makeLink("https://mirror.example.com/books/example.epub", "epub");
const auto secondary = makeLink("https://mirror.example.com/books/example-copy.epub", "epub");
const std::vector<OpdsAcquisitionLink> links{primary, secondary};
ASSERT_EQ(opdsFormatSelectionLabel(primary, links, "catalog.example.com"), "EPUB - mirror.example.com (1)");
ASSERT_EQ(opdsFormatSelectionLabel(secondary, links, "catalog.example.com"), "EPUB - mirror.example.com (2)");
PASS();
}
void testBatchLabelBuilderMatchesPerLinkLabels() {
printf("testBatchLabelBuilderMatchesPerLinkLabels...\n");
const auto first = makeLink("https://mirror.example.com/books/example.epub", "epub");
const auto second = makeLink("https://mirror.example.com/books/example-copy.epub", "epub");
const auto third = makeLink("/books/example.txt", "txt");
const std::vector<OpdsAcquisitionLink> links{first, second, third};
const auto labels = buildOpdsFormatSelectionLabels(links, "https://catalog.example.com/opds");
ASSERT_EQ(labels.size(), static_cast<size_t>(3));
ASSERT_EQ(labels[0], opdsFormatSelectionLabel(first, links, "https://catalog.example.com/opds"));
ASSERT_EQ(labels[1], opdsFormatSelectionLabel(second, links, "https://catalog.example.com/opds"));
ASSERT_EQ(labels[2], opdsFormatSelectionLabel(third, links, "https://catalog.example.com/opds"));
PASS();
}
int main() {
printf("=== OPDS Format Label Tests ===\n\n");
testUniqueFormatUsesBaseLabel();
testDuplicateAbsoluteUrlsIncludeHostname();
testDuplicateRootRelativeUrlsUseServerHostname();
testDuplicateRelativeUrlsUseServerHostname();
testDuplicateAbsoluteUrlsSameHostnameIncludeNumbering();
testBatchLabelBuilderMatchesPerLinkLabels();
printf("\n=== Results: %d passed, %d failed ===\n", testsPassed, testsFailed);
return testsFailed > 0 ? 1 : 0;
}
+333
View File
@@ -0,0 +1,333 @@
#include <OpdsParser.h>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <string>
static int testsPassed = 0;
static int testsFailed = 0;
#define ASSERT_TRUE(cond) \
do { \
if (!(cond)) { \
fprintf(stderr, " FAIL: %s:%d: %s\n", __FILE__, __LINE__, #cond); \
testsFailed++; \
return; \
} \
} while (0)
#define ASSERT_EQ(a, b) \
do { \
if ((a) != (b)) { \
fprintf(stderr, " FAIL: %s:%d: %s != %s\n", __FILE__, __LINE__, #a, #b); \
testsFailed++; \
return; \
} \
} while (0)
#define ASSERT_SIZE(a, b) \
do { \
if ((a) != (b)) { \
fprintf(stderr, " FAIL: %s:%d: %s == %zu, expected %zu\n", __FILE__, __LINE__, #a, static_cast<size_t>(a), \
static_cast<size_t>(b)); \
testsFailed++; \
return; \
} \
} while (0)
#define PASS() testsPassed++
namespace {
bool parseSingleBookEntry(OpdsEntry& entryOut, const char* href, const char* type = "application/epub+zip") {
const std::string xml = std::string(R"(<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<entry>
<title>Example Book</title>
<author><name>Example Author</name></author>
<id>book-1</id>
<link rel="http://opds-spec.org/acquisition" type=")") +
type + R"(" href=")" + href + R"("/>
</entry>
</feed>)";
OpdsParser parser;
parser.write(reinterpret_cast<const uint8_t*>(xml.data()), xml.size());
parser.flush();
if (parser.error()) {
fprintf(stderr, " FAIL: %s:%d: parser.error()\n", __FILE__, __LINE__);
testsFailed++;
return false;
}
const auto& entries = parser.getEntries();
if (entries.size() != 1) {
fprintf(stderr, " FAIL: %s:%d: entries.size() == %zu, expected 1\n", __FILE__, __LINE__, entries.size());
testsFailed++;
return false;
}
entryOut = entries.front();
return true;
}
bool assertSingleFormat(const OpdsEntry& entry, const char* formatKey, const char* fileExtension) {
if (entry.type != OpdsEntryType::BOOK) {
fprintf(stderr, " FAIL: %s:%d: entry.type != OpdsEntryType::BOOK\n", __FILE__, __LINE__);
testsFailed++;
return false;
}
if (entry.acquisitionLinks.size() != 1) {
fprintf(stderr, " FAIL: %s:%d: entry.acquisitionLinks.size() == %zu, expected 1\n", __FILE__, __LINE__,
entry.acquisitionLinks.size());
testsFailed++;
return false;
}
if (entry.acquisitionLinks[0].formatKey != formatKey) {
fprintf(stderr, " FAIL: %s:%d: formatKey actual='%s' expected='%s'\n", __FILE__, __LINE__,
entry.acquisitionLinks[0].formatKey.c_str(), formatKey);
testsFailed++;
return false;
}
if (entry.acquisitionLinks[0].fileExtension != fileExtension) {
fprintf(stderr, " FAIL: %s:%d: fileExtension actual='%s' expected='%s'\n", __FILE__, __LINE__,
entry.acquisitionLinks[0].fileExtension.c_str(), fileExtension);
testsFailed++;
return false;
}
return true;
}
} // namespace
void testEpubExtension() {
printf("testEpubExtension...\n");
OpdsEntry entry;
if (!parseSingleBookEntry(entry, "/books/example.epub")) return;
ASSERT_TRUE(assertSingleFormat(entry, "epub", ".epub"));
PASS();
}
void testKepubDoubleExtension() {
printf("testKepubDoubleExtension...\n");
OpdsEntry entry;
if (!parseSingleBookEntry(entry, "/books/example.kepub.epub")) return;
ASSERT_TRUE(assertSingleFormat(entry, "kepub", ".kepub.epub"));
PASS();
}
void testBareKepubExtension() {
printf("testBareKepubExtension...\n");
OpdsEntry entry;
if (!parseSingleBookEntry(entry, "/books/example.kepub")) return;
ASSERT_TRUE(assertSingleFormat(entry, "kepub", ".kepub.epub"));
PASS();
}
void testSlashTerminatedKepubPath() {
printf("testSlashTerminatedKepubPath...\n");
OpdsEntry entry;
if (!parseSingleBookEntry(entry, "/opds/download/6516/kepub/")) return;
ASSERT_TRUE(assertSingleFormat(entry, "kepub", ".kepub.epub"));
PASS();
}
void testSlashTerminatedEpubPath() {
printf("testSlashTerminatedEpubPath...\n");
OpdsEntry entry;
if (!parseSingleBookEntry(entry, "/opds/download/6516/epub/")) return;
ASSERT_TRUE(assertSingleFormat(entry, "epub", ".epub"));
PASS();
}
void testDistinctAcquisitionFormatsRemainSeparate() {
printf("testDistinctAcquisitionFormatsRemainSeparate...\n");
const char* xml = R"(<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<entry>
<title>Example Book</title>
<author><name>Example Author</name></author>
<id>book-2</id>
<link rel="http://opds-spec.org/acquisition" type="application/epub+zip" href="/books/example.epub"/>
<link rel="http://opds-spec.org/acquisition" type="application/epub+zip" href="/books/example.kepub.epub"/>
<link rel="http://opds-spec.org/acquisition" type="text/plain" href="/books/example.txt"/>
<link rel="http://opds-spec.org/acquisition" type="text/markdown" href="/books/example.md"/>
</entry>
</feed>)";
OpdsParser parser;
parser.write(reinterpret_cast<const uint8_t*>(xml), strlen(xml));
parser.flush();
ASSERT_TRUE(!parser.error());
const auto& entries = parser.getEntries();
ASSERT_SIZE(entries.size(), 1);
const auto& links = entries.front().acquisitionLinks;
ASSERT_SIZE(links.size(), 4);
ASSERT_EQ(links[0].formatKey, "epub");
ASSERT_EQ(links[1].formatKey, "kepub");
ASSERT_EQ(links[2].formatKey, "txt");
ASSERT_EQ(links[3].formatKey, "md");
PASS();
}
void testUnsupportedMimeType() {
printf("testUnsupportedMimeType...\n");
const char* xml = R"(<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<entry>
<title>Example Book</title>
<author><name>Example Author</name></author>
<id>book-3</id>
<link rel="http://opds-spec.org/acquisition" type="application/x-mobipocket-ebook" href="/books/example.mobi"/>
</entry>
</feed>)";
OpdsParser parser;
parser.write(reinterpret_cast<const uint8_t*>(xml), strlen(xml));
parser.flush();
ASSERT_TRUE(!parser.error());
ASSERT_SIZE(parser.getEntries().size(), 0);
PASS();
}
void testEmptyHrefOrType() {
printf("testEmptyHrefOrType...\n");
const char* xml = R"(<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<entry>
<title>Empty Href</title>
<author><name>Example Author</name></author>
<id>book-4</id>
<link rel="http://opds-spec.org/acquisition" type="application/epub+zip" href=""/>
</entry>
<entry>
<title>Empty Type</title>
<author><name>Example Author</name></author>
<id>book-5</id>
<link rel="http://opds-spec.org/acquisition" type="" href="/books/example.epub"/>
</entry>
<entry>
<title>Missing Href</title>
<author><name>Example Author</name></author>
<id>book-6</id>
<link rel="http://opds-spec.org/acquisition" type="application/epub+zip"/>
</entry>
<entry>
<title>Missing Type</title>
<author><name>Example Author</name></author>
<id>book-7</id>
<link rel="http://opds-spec.org/acquisition" href="/books/example.epub"/>
</entry>
</feed>)";
OpdsParser parser;
parser.write(reinterpret_cast<const uint8_t*>(xml), strlen(xml));
parser.flush();
ASSERT_TRUE(!parser.error());
const auto& entries = parser.getEntries();
ASSERT_SIZE(entries.size(), 0);
PASS();
}
void testDuplicateAcquisitionLinks() {
printf("testDuplicateAcquisitionLinks...\n");
const char* xml = R"(<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<entry>
<title>Example Book</title>
<author><name>Example Author</name></author>
<id>book-8</id>
<link rel="http://opds-spec.org/acquisition" type="application/epub+zip" href="/books/example.epub"/>
<link rel="http://opds-spec.org/acquisition" type="application/epub+zip" href="/books/example-copy.epub"/>
</entry>
</feed>)";
OpdsParser parser;
parser.write(reinterpret_cast<const uint8_t*>(xml), strlen(xml));
parser.flush();
ASSERT_TRUE(!parser.error());
const auto& entries = parser.getEntries();
ASSERT_SIZE(entries.size(), 1);
const auto& links = entries.front().acquisitionLinks;
ASSERT_SIZE(links.size(), 2);
ASSERT_EQ(links[0].formatKey, "epub");
ASSERT_EQ(links[0].href, "/books/example.epub");
ASSERT_EQ(links[1].formatKey, "epub");
ASSERT_EQ(links[1].href, "/books/example-copy.epub");
PASS();
}
void testIdenticalHrefAcquisitionLinksAreDeduplicated() {
printf("testIdenticalHrefAcquisitionLinksAreDeduplicated...\n");
const char* xml = R"(<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<entry>
<title>Example Book</title>
<author><name>Example Author</name></author>
<id>book-9</id>
<link rel="http://opds-spec.org/acquisition" type="application/epub+zip" href="/books/example.epub"/>
<link rel="http://opds-spec.org/acquisition" type="application/epub+zip" href="/books/example.epub"/>
</entry>
</feed>)";
OpdsParser parser;
parser.write(reinterpret_cast<const uint8_t*>(xml), strlen(xml));
parser.flush();
ASSERT_TRUE(!parser.error());
const auto& entries = parser.getEntries();
ASSERT_SIZE(entries.size(), 1);
const auto& links = entries.front().acquisitionLinks;
ASSERT_SIZE(links.size(), 1);
ASSERT_EQ(links[0].formatKey, "epub");
ASSERT_EQ(links[0].href, "/books/example.epub");
PASS();
}
void testSlashVariantHrefAcquisitionLinksAreDeduplicated() {
printf("testSlashVariantHrefAcquisitionLinksAreDeduplicated...\n");
const char* xml = R"(<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<entry>
<title>Example Book</title>
<author><name>Example Author</name></author>
<id>book-10</id>
<link rel="http://opds-spec.org/acquisition" type="application/epub+zip" href="/books/example.epub"/>
<link rel="http://opds-spec.org/acquisition" type="application/epub+zip" href="/books/example.epub/"/>
</entry>
</feed>)";
OpdsParser parser;
parser.write(reinterpret_cast<const uint8_t*>(xml), strlen(xml));
parser.flush();
ASSERT_TRUE(!parser.error());
const auto& entries = parser.getEntries();
ASSERT_SIZE(entries.size(), 1);
const auto& links = entries.front().acquisitionLinks;
ASSERT_SIZE(links.size(), 1);
ASSERT_EQ(links[0].formatKey, "epub");
ASSERT_EQ(links[0].href, "/books/example.epub");
PASS();
}
int main() {
printf("=== OPDS Parser Tests ===\n\n");
testEpubExtension();
testKepubDoubleExtension();
testBareKepubExtension();
testSlashTerminatedKepubPath();
testSlashTerminatedEpubPath();
testDistinctAcquisitionFormatsRemainSeparate();
testUnsupportedMimeType();
testEmptyHrefOrType();
testDuplicateAcquisitionLinks();
testIdenticalHrefAcquisitionLinksAreDeduplicated();
testSlashVariantHrefAcquisitionLinksAreDeduplicated();
printf("\n=== Results: %d passed, %d failed ===\n", testsPassed, testsFailed);
return testsFailed > 0 ? 1 : 0;
}
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
BUILD_DIR="$ROOT_DIR/build/opds_format_label"
BINARY="$BUILD_DIR/OpdsFormatLabelTest"
PLATFORMIO_DIR="${PLATFORMIO_CORE_DIR:-$HOME/.platformio}"
ARDUINO_FRAMEWORK_DIR="$PLATFORMIO_DIR/packages/framework-arduinoespressif32"
mkdir -p "$BUILD_DIR"
SOURCES=(
"$ROOT_DIR/test/opds_format_label/OpdsFormatLabelTest.cpp"
"$ROOT_DIR/src/activities/browser/OpdsFormatLabel.cpp"
"$ROOT_DIR/src/util/UrlUtils.cpp"
)
CXXFLAGS=(
-std=c++20
-O2
-Wall
-Wextra
-pedantic
-fno-exceptions
-DARDUINO_USB_MODE=1
-DARDUINO_USB_CDC_ON_BOOT=1
-DDESTRUCTOR_CLOSES_FILE=1
-I"$ROOT_DIR/test/shims"
-I"$ROOT_DIR"
-I"$ROOT_DIR/lib"
-I"$ROOT_DIR/lib/OpdsParser"
-I"$ROOT_DIR/src"
-I"$ARDUINO_FRAMEWORK_DIR/cores/esp32"
-I"$ARDUINO_FRAMEWORK_DIR/variants/esp32c3"
)
c++ "${CXXFLAGS[@]}" "${SOURCES[@]}" -o "$BINARY"
"$BINARY" "$@"
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
BUILD_DIR="$ROOT_DIR/build/opds_parser"
BINARY="$BUILD_DIR/OpdsParserTest"
PLATFORMIO_DIR="${PLATFORMIO_CORE_DIR:-$HOME/.platformio}"
ARDUINO_FRAMEWORK_DIR="$PLATFORMIO_DIR/packages/framework-arduinoespressif32"
mkdir -p "$BUILD_DIR"
SOURCES=(
"$ROOT_DIR/test/opds_parser/OpdsParserTest.cpp"
"$ROOT_DIR/lib/FsHelpers/FsHelpers.cpp"
"$ROOT_DIR/lib/OpdsParser/OpdsParser.cpp"
)
CXXFLAGS=(
-std=c++20
-O2
-Wall
-Wextra
-pedantic
-fno-exceptions
-DARDUINO_USB_MODE=1
-DARDUINO_USB_CDC_ON_BOOT=1
-DDESTRUCTOR_CLOSES_FILE=1
-DXML_GE=0
-DXML_CONTEXT_BYTES=1024
-DUSE_UTF8_LONG_NAMES=1
-I"$ROOT_DIR/test/shims"
-I"$ROOT_DIR"
-I"$ROOT_DIR/lib"
-I"$ROOT_DIR/lib/FsHelpers"
-I"$ROOT_DIR/lib/OpdsParser"
-I"$ROOT_DIR/lib/Logging"
-I"$ARDUINO_FRAMEWORK_DIR/cores/esp32"
-I"$ARDUINO_FRAMEWORK_DIR/variants/esp32c3"
)
c++ "${CXXFLAGS[@]}" "${SOURCES[@]}" -lexpat -o "$BINARY"
"$BINARY" "$@"
+15
View File
@@ -0,0 +1,15 @@
#pragma once
#include "Print.h"
class HWCDC : public Print {
public:
void begin(unsigned long) {}
operator bool() const { return true; }
size_t write(uint8_t) override { return 1; }
size_t write(const uint8_t*, size_t size) override { return size; }
void flush() override {}
};
inline HWCDC Serial;
+21
View File
@@ -0,0 +1,21 @@
#pragma once
#include <cstddef>
#include <cstdint>
class Print {
public:
virtual ~Print() = default;
virtual size_t write(uint8_t) = 0;
virtual size_t write(const uint8_t* buffer, size_t size) {
size_t written = 0;
for (size_t i = 0; i < size; i++) {
written += write(buffer[i]);
}
return written;
}
virtual void flush() {}
};