lets you navigate and download books now

This commit is contained in:
Justin Mitchell 2026-01-02 19:59:43 -05:00
parent c99b93435f
commit 231393703c
4 changed files with 205 additions and 72 deletions

View File

@ -60,13 +60,13 @@ bool OpdsParser::parse(const char* xmlData, const size_t length) {
XML_ParserFree(parser);
parser = nullptr;
Serial.printf("[%lu] [OPDS] Parsed %zu books\n", millis(), books.size());
Serial.printf("[%lu] [OPDS] Parsed %zu entries\n", millis(), entries.size());
return true;
}
void OpdsParser::clear() {
books.clear();
currentBook = OpdsBook{};
entries.clear();
currentEntry = OpdsEntry{};
currentText.clear();
inEntry = false;
inTitle = false;
@ -75,6 +75,16 @@ void OpdsParser::clear() {
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) {
@ -90,7 +100,7 @@ void XMLCALL OpdsParser::startElement(void* userData, const XML_Char* name, cons
// Check for entry element (with or without namespace prefix)
if (strcmp(name, "entry") == 0 || strstr(name, ":entry") != nullptr) {
self->inEntry = true;
self->currentBook = OpdsBook{};
self->currentEntry = OpdsEntry{};
return;
}
@ -123,16 +133,26 @@ void XMLCALL OpdsParser::startElement(void* userData, const XML_Char* name, cons
return;
}
// Check for link element with acquisition rel and epub type
// Check for link element
if (strcmp(name, "link") == 0 || strstr(name, ":link") != nullptr) {
const char* rel = findAttribute(atts, "rel");
const char* type = findAttribute(atts, "type");
const char* href = findAttribute(atts, "href");
// Look for acquisition link with epub type
if (rel && type && href) {
if (strstr(rel, "opds-spec.org/acquisition") != nullptr && strcmp(type, "application/epub+zip") == 0) {
self->currentBook.epubUrl = href;
if (href) {
// Check for acquisition link with epub type (this is a downloadable book)
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;
}
// Check for navigation link (subsection or no rel specified with atom+xml type)
else if (type && strstr(type, "application/atom+xml") != nullptr) {
// Only set navigation link if we don't already have an epub link
if (self->currentEntry.type != OpdsEntryType::BOOK) {
self->currentEntry.type = OpdsEntryType::NAVIGATION;
self->currentEntry.href = href;
}
}
}
}
@ -143,12 +163,12 @@ void XMLCALL OpdsParser::endElement(void* userData, const XML_Char* name) {
// Check for entry end
if (strcmp(name, "entry") == 0 || strstr(name, ":entry") != nullptr) {
// Only add book if it has required fields
if (!self->currentBook.title.empty() && !self->currentBook.epubUrl.empty()) {
self->books.push_back(self->currentBook);
// Only add entry if it has required fields (title and href)
if (!self->currentEntry.title.empty() && !self->currentEntry.href.empty()) {
self->entries.push_back(self->currentEntry);
}
self->inEntry = false;
self->currentBook = OpdsBook{};
self->currentEntry = OpdsEntry{};
return;
}
@ -157,7 +177,7 @@ void XMLCALL OpdsParser::endElement(void* userData, const XML_Char* name) {
// Check for title end
if (strcmp(name, "title") == 0 || strstr(name, ":title") != nullptr) {
if (self->inTitle) {
self->currentBook.title = self->currentText;
self->currentEntry.title = self->currentText;
}
self->inTitle = false;
return;
@ -172,7 +192,7 @@ void XMLCALL OpdsParser::endElement(void* userData, const XML_Char* name) {
// Check for author name end
if (self->inAuthor && (strcmp(name, "name") == 0 || strstr(name, ":name") != nullptr)) {
if (self->inAuthorName) {
self->currentBook.author = self->currentText;
self->currentEntry.author = self->currentText;
}
self->inAuthorName = false;
return;
@ -181,7 +201,7 @@ void XMLCALL OpdsParser::endElement(void* userData, const XML_Char* name) {
// Check for id end
if (strcmp(name, "id") == 0 || strstr(name, ":id") != nullptr) {
if (self->inId) {
self->currentBook.id = self->currentText;
self->currentEntry.id = self->currentText;
}
self->inId = false;
return;

View File

@ -5,15 +5,27 @@
#include <vector>
/**
* Represents a book entry from an OPDS feed.
* Type of OPDS entry.
*/
struct OpdsBook {
enum class OpdsEntryType {
NAVIGATION, // Link to another catalog
BOOK // Downloadable book
};
/**
* Represents an entry from an OPDS feed (either a navigation link or a book).
*/
struct OpdsEntry {
OpdsEntryType type = OpdsEntryType::NAVIGATION;
std::string title;
std::string author;
std::string epubUrl; // Relative URL like "/books/get/epub/3/Calibre_Library"
std::string author; // Only for books
std::string href; // Navigation URL or epub download URL
std::string id;
};
// Legacy alias for backward compatibility
using OpdsBook = OpdsEntry;
/**
* Parser for OPDS (Open Publication Distribution System) Atom feeds.
* Uses the Expat XML parser to parse OPDS catalog entries.
@ -21,8 +33,12 @@ struct OpdsBook {
* Usage:
* OpdsParser parser;
* if (parser.parse(xmlData, xmlLength)) {
* for (const auto& book : parser.getBooks()) {
* // Process book entries
* for (const auto& entry : parser.getEntries()) {
* if (entry.type == OpdsEntryType::BOOK) {
* // Downloadable book
* } else {
* // Navigation link to another catalog
* }
* }
* }
*/
@ -44,13 +60,19 @@ class OpdsParser {
bool parse(const char* xmlData, size_t length);
/**
* Get the parsed books.
* @return Vector of OpdsBook entries
* Get the parsed entries (both navigation and book entries).
* @return Vector of OpdsEntry entries
*/
const std::vector<OpdsBook>& getBooks() const { return books; }
const std::vector<OpdsEntry>& getEntries() const { return entries; }
/**
* Clear all parsed books.
* Get only book entries (legacy compatibility).
* @return Vector of book entries
*/
std::vector<OpdsEntry> getBooks() const;
/**
* Clear all parsed entries.
*/
void clear();
@ -64,8 +86,8 @@ class OpdsParser {
static const char* findAttribute(const XML_Char** atts, const char* name);
XML_Parser parser = nullptr;
std::vector<OpdsBook> books;
OpdsBook currentBook;
std::vector<OpdsEntry> entries;
OpdsEntry currentEntry;
std::string currentText;
// Parser state

View File

@ -11,7 +11,7 @@
namespace {
constexpr int PAGE_ITEMS = 23;
constexpr int SKIP_PAGE_MS = 700;
constexpr char OPDS_FEED_PATH[] = "/opds";
constexpr char OPDS_ROOT_PATH[] = "opds"; // No leading slash - relative to server URL
// Prepend http:// if no protocol specified (server will redirect to https if needed)
std::string ensureProtocol(const std::string& url) {
@ -20,6 +20,39 @@ std::string ensureProtocol(const std::string& url) {
}
return url;
}
// Extract host with protocol from URL (e.g., "http://example.com" from "http://example.com/path")
std::string extractHost(const std::string& url) {
const size_t protocolEnd = url.find("://");
if (protocolEnd == std::string::npos) {
// No protocol, find first slash
const size_t firstSlash = url.find('/');
return firstSlash == std::string::npos ? url : url.substr(0, firstSlash);
}
// Find the first slash after the protocol
const size_t hostStart = protocolEnd + 3;
const size_t pathStart = url.find('/', hostStart);
return pathStart == std::string::npos ? url : url.substr(0, pathStart);
}
// Build full URL from server URL and path
// If path starts with /, it's an absolute path from the host root
// Otherwise, it's relative to the server URL
std::string buildUrl(const std::string& serverUrl, const std::string& path) {
const std::string urlWithProtocol = ensureProtocol(serverUrl);
if (path.empty()) {
return urlWithProtocol;
}
if (path[0] == '/') {
// Absolute path - use just the host
return extractHost(urlWithProtocol) + path;
}
// Relative path - append to server URL
if (urlWithProtocol.back() == '/') {
return urlWithProtocol + path;
}
return urlWithProtocol + "/" + path;
}
} // namespace
void OpdsBookBrowserActivity::taskTrampoline(void* param) {
@ -32,7 +65,9 @@ void OpdsBookBrowserActivity::onEnter() {
renderingMutex = xSemaphoreCreateMutex();
state = BrowserState::LOADING;
books.clear();
entries.clear();
navigationHistory.clear();
currentPath = OPDS_ROOT_PATH;
selectorIndex = 0;
errorMessage.clear();
statusMessage = "Loading...";
@ -45,8 +80,8 @@ void OpdsBookBrowserActivity::onEnter() {
&displayTaskHandle // Task handle
);
// Fetch books after setting up the display task
fetchBooks();
// Fetch feed after setting up the display task
fetchFeed(currentPath);
}
void OpdsBookBrowserActivity::onExit() {
@ -59,27 +94,28 @@ void OpdsBookBrowserActivity::onExit() {
}
vSemaphoreDelete(renderingMutex);
renderingMutex = nullptr;
books.clear();
entries.clear();
navigationHistory.clear();
}
void OpdsBookBrowserActivity::loop() {
// Handle error state - Confirm retries, Back goes home
// Handle error state - Confirm retries, Back goes back or home
if (state == BrowserState::ERROR) {
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
state = BrowserState::LOADING;
statusMessage = "Loading...";
updateRequired = true;
fetchBooks();
fetchFeed(currentPath);
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
onGoHome();
navigateBack();
}
return;
}
// Handle loading state - only Back goes home
// Handle loading state - only Back works
if (state == BrowserState::LOADING) {
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
onGoHome();
navigateBack();
}
return;
}
@ -89,8 +125,8 @@ void OpdsBookBrowserActivity::loop() {
return;
}
// Handle book list state
if (state == BrowserState::BOOK_LIST) {
// Handle browsing state
if (state == BrowserState::BROWSING) {
const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::Up) ||
mappedInput.wasReleased(MappedInputManager::Button::Left);
const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::Down) ||
@ -98,23 +134,28 @@ void OpdsBookBrowserActivity::loop() {
const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS;
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (!books.empty()) {
downloadBook(books[selectorIndex]);
if (!entries.empty()) {
const auto& entry = entries[selectorIndex];
if (entry.type == OpdsEntryType::BOOK) {
downloadBook(entry);
} else {
navigateToEntry(entry);
}
}
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
onGoHome();
} else if (prevReleased && !books.empty()) {
navigateBack();
} else if (prevReleased && !entries.empty()) {
if (skipPage) {
selectorIndex = ((selectorIndex / PAGE_ITEMS - 1) * PAGE_ITEMS + books.size()) % books.size();
selectorIndex = ((selectorIndex / PAGE_ITEMS - 1) * PAGE_ITEMS + entries.size()) % entries.size();
} else {
selectorIndex = (selectorIndex + books.size() - 1) % books.size();
selectorIndex = (selectorIndex + entries.size() - 1) % entries.size();
}
updateRequired = true;
} else if (nextReleased && !books.empty()) {
} else if (nextReleased && !entries.empty()) {
if (skipPage) {
selectorIndex = ((selectorIndex / PAGE_ITEMS + 1) * PAGE_ITEMS) % books.size();
selectorIndex = ((selectorIndex / PAGE_ITEMS + 1) * PAGE_ITEMS) % entries.size();
} else {
selectorIndex = (selectorIndex + 1) % books.size();
selectorIndex = (selectorIndex + 1) % entries.size();
}
updateRequired = true;
}
@ -171,12 +212,17 @@ void OpdsBookBrowserActivity::render() const {
return;
}
// Book list state
const auto labels = mappedInput.mapLabels("« Back", "Download", "", "");
// Browsing state
// Show appropriate button hint based on selected entry type
const char* confirmLabel = "Open";
if (!entries.empty() && entries[selectorIndex].type == OpdsEntryType::BOOK) {
confirmLabel = "Download";
}
const auto labels = mappedInput.mapLabels("« Back", confirmLabel, "", "");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
if (books.empty()) {
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, "No books found");
if (entries.empty()) {
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, "No entries found");
renderer.displayBuffer();
return;
}
@ -184,11 +230,19 @@ void OpdsBookBrowserActivity::render() const {
const auto pageStartIndex = selectorIndex / PAGE_ITEMS * PAGE_ITEMS;
renderer.fillRect(0, 60 + (selectorIndex % PAGE_ITEMS) * 30 - 2, pageWidth - 1, 30);
for (size_t i = pageStartIndex; i < books.size() && i < static_cast<size_t>(pageStartIndex + PAGE_ITEMS); i++) {
// Format: "Title - Author" or just "Title" if no author
std::string displayText = books[i].title;
if (!books[i].author.empty()) {
displayText += " - " + books[i].author;
for (size_t i = pageStartIndex; i < entries.size() && i < static_cast<size_t>(pageStartIndex + PAGE_ITEMS); i++) {
const auto& entry = entries[i];
// Format display text with type indicator
std::string displayText;
if (entry.type == OpdsEntryType::NAVIGATION) {
displayText = "> " + entry.title; // Folder/navigation indicator
} else {
// Book: "Title - Author" or just "Title"
displayText = entry.title;
if (!entry.author.empty()) {
displayText += " - " + entry.author;
}
}
auto item = renderer.truncatedText(UI_10_FONT_ID, displayText.c_str(), renderer.getScreenWidth() - 40);
@ -198,7 +252,7 @@ void OpdsBookBrowserActivity::render() const {
renderer.displayBuffer();
}
void OpdsBookBrowserActivity::fetchBooks() {
void OpdsBookBrowserActivity::fetchFeed(const std::string& path) {
const char* serverUrl = SETTINGS.opdsServerUrl;
if (strlen(serverUrl) == 0) {
state = BrowserState::ERROR;
@ -207,7 +261,7 @@ void OpdsBookBrowserActivity::fetchBooks() {
return;
}
std::string url = ensureProtocol(serverUrl) + OPDS_FEED_PATH;
std::string url = buildUrl(serverUrl, path);
Serial.printf("[%lu] [OPDS] Fetching: %s\n", millis(), url.c_str());
std::string content;
@ -226,21 +280,54 @@ void OpdsBookBrowserActivity::fetchBooks() {
return;
}
books = parser.getBooks();
entries = parser.getEntries();
selectorIndex = 0;
if (books.empty()) {
if (entries.empty()) {
state = BrowserState::ERROR;
errorMessage = "No books found";
errorMessage = "No entries found";
updateRequired = true;
return;
}
state = BrowserState::BOOK_LIST;
state = BrowserState::BROWSING;
updateRequired = true;
}
void OpdsBookBrowserActivity::downloadBook(const OpdsBook& book) {
void OpdsBookBrowserActivity::navigateToEntry(const OpdsEntry& entry) {
// Push current path to history before navigating
navigationHistory.push_back(currentPath);
currentPath = entry.href;
state = BrowserState::LOADING;
statusMessage = "Loading...";
entries.clear();
selectorIndex = 0;
updateRequired = true;
fetchFeed(currentPath);
}
void OpdsBookBrowserActivity::navigateBack() {
if (navigationHistory.empty()) {
// At root, go home
onGoHome();
} else {
// Go back to previous catalog
currentPath = navigationHistory.back();
navigationHistory.pop_back();
state = BrowserState::LOADING;
statusMessage = "Loading...";
entries.clear();
selectorIndex = 0;
updateRequired = true;
fetchFeed(currentPath);
}
}
void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) {
state = BrowserState::DOWNLOADING;
statusMessage = book.title;
downloadProgress = 0;
@ -248,7 +335,7 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsBook& book) {
updateRequired = true;
// Build full download URL
std::string downloadUrl = ensureProtocol(SETTINGS.opdsServerUrl) + book.epubUrl;
std::string downloadUrl = buildUrl(SETTINGS.opdsServerUrl, book.href);
// Create sanitized filename
std::string filename = "/" + sanitizeFilename(book.title) + ".epub";
@ -264,7 +351,7 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsBook& book) {
if (result == HttpDownloader::OK) {
Serial.printf("[%lu] [OPDS] Download complete: %s\n", millis(), filename.c_str());
state = BrowserState::BOOK_LIST;
state = BrowserState::BROWSING;
updateRequired = true;
} else {
state = BrowserState::ERROR;

View File

@ -12,13 +12,13 @@
/**
* Activity for browsing and downloading books from an OPDS server.
* Displays a list of available books and allows downloading EPUBs.
* Supports navigation through catalog hierarchy and downloading EPUBs.
*/
class OpdsBookBrowserActivity final : public Activity {
public:
enum class BrowserState {
LOADING, // Fetching OPDS feed
BOOK_LIST, // Displaying books
BROWSING, // Displaying entries (navigation or books)
DOWNLOADING, // Downloading selected EPUB
ERROR // Error state with message
};
@ -37,7 +37,9 @@ class OpdsBookBrowserActivity final : public Activity {
bool updateRequired = false;
BrowserState state = BrowserState::LOADING;
std::vector<OpdsBook> books;
std::vector<OpdsEntry> entries;
std::vector<std::string> navigationHistory; // Stack of previous feed paths for back navigation
std::string currentPath; // Current feed path being displayed
int selectorIndex = 0;
std::string errorMessage;
std::string statusMessage;
@ -50,7 +52,9 @@ class OpdsBookBrowserActivity final : public Activity {
[[noreturn]] void displayTaskLoop();
void render() const;
void fetchBooks();
void downloadBook(const OpdsBook& book);
void fetchFeed(const std::string& path);
void navigateToEntry(const OpdsEntry& entry);
void navigateBack();
void downloadBook(const OpdsEntry& book);
std::string sanitizeFilename(const std::string& title) const;
};