feat: support two-step OpenSearch Description for OPDS search

Servers like copyparty advertise search via a link to an OpenSearch
Description (OSD) document rather than embedding {searchTerms} directly
in the feed's <link rel="search"> href (the Calibre-Web style).

OpdsParser now stores the OSD URL when rel="search" and
type="application/opensearchdescription+xml" but the href lacks
{searchTerms}. OpdsBookBrowserActivity fetches that OSD document after
each feed load and parses the <Url template="..."> element with a
minimal inline expat parser to extract the actual search template.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Brandon Philips
2026-04-15 17:58:02 +02:00
committed by jpirnay
co-authored by Claude Sonnet 4.6
parent bd43221ad9
commit b47e5b5fff
4 changed files with 46 additions and 0 deletions
@@ -7,6 +7,7 @@
#include <Logging.h>
#include <OpdsStream.h>
#include <WiFi.h>
#include <expat.h>
#include <cctype>
#include <cstdio>
@@ -245,6 +246,9 @@ void OpdsBookBrowserActivity::fetchFeed(const std::string& path) {
}
searchTemplate = parser.getSearchTemplate();
if (searchTemplate.empty() && !parser.getOsdUrl().empty()) {
fetchOsdTemplate(UrlUtils::buildUrl(url, parser.getOsdUrl()));
}
const auto& nextUrl = parser.getNextPageUrl();
const auto& prevUrl = parser.getPrevPageUrl();
entries = std::move(parser).getEntries();
@@ -336,6 +340,42 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) {
}
}
void OpdsBookBrowserActivity::fetchOsdTemplate(const std::string& osdUrl) {
std::string content;
if (!HttpDownloader::fetchUrl(osdUrl, content)) {
LOG_ERR("OPDS", "Failed to fetch OSD: %s", osdUrl.c_str());
return;
}
struct OsdState {
std::string templateUrl;
static void XMLCALL onStart(void* ud, const XML_Char* name, const XML_Char** atts) {
if (strcmp(name, "Url") != 0 && strstr(name, ":Url") == nullptr) return;
auto* state = static_cast<OsdState*>(ud);
for (int i = 0; atts[i]; i += 2) {
if (strcmp(atts[i], "template") == 0 && strstr(atts[i + 1], "{searchTerms}") != nullptr) {
state->templateUrl = atts[i + 1];
break;
}
}
}
} osdState;
XML_Parser p = XML_ParserCreate(nullptr);
if (!p) {
LOG_ERR("OPDS", "OSD parser alloc failed");
return;
}
XML_SetUserData(p, &osdState);
XML_SetElementHandler(p, OsdState::onStart, nullptr);
XML_Parse(p, content.c_str(), static_cast<int>(content.size()), XML_TRUE);
XML_ParserFree(p);
if (!osdState.templateUrl.empty()) {
searchTemplate = UrlUtils::buildUrl(osdUrl, osdState.templateUrl);
}
}
void OpdsBookBrowserActivity::launchSearch() {
consumeConfirm = true;
state = BrowserState::SEARCH_INPUT;