feat: port upstream OPDS improvements (PRs #1207, #1209)

Port two upstream PRs:

- PR #1207: Replace manual chunked download loop with
  HTTPClient::writeToStream via a FileWriteStream adapter, improving
  reliability for OPDS file downloads including chunked transfers.

- PR #1209: Add support for multiple OPDS servers with a new
  OpdsServerStore (JSON persistence with MAC-based password obfuscation),
  OpdsServerListActivity and OpdsSettingsActivity UIs, per-server
  credentials passed to HttpDownloader, web UI management endpoints,
  and migration from legacy single-server settings.

Made-with: Cursor
This commit is contained in:
cottongin
2026-02-26 19:14:59 -05:00
parent 19b6ad047b
commit 2aa13ea2de
30 changed files with 1119 additions and 278 deletions

View File

@@ -416,6 +416,12 @@ enum class StrId : uint16_t {
STR_ACTION_FAILED,
STR_BACK_TO_BEGINNING,
STR_CLOSE_MENU,
STR_ADD_SERVER,
STR_SERVER_NAME,
STR_NO_SERVERS,
STR_DELETE_SERVER,
STR_DELETE_CONFIRM,
STR_OPDS_SERVERS,
// Sentinel - must be last
_COUNT
};

View File

@@ -344,3 +344,9 @@ STR_INDEXING_STATUS_ICON: "Ikona stavového řádku"
STR_SYNC_CLOCK: "Sync Clock"
STR_TIME_SYNCED: "Time synced!"
STR_AUTO_NTP_SYNC: "Auto Sync on Boot"
STR_ADD_SERVER: "Přidat server"
STR_SERVER_NAME: "Název serveru"
STR_NO_SERVERS: "Žádné OPDS servery nejsou nakonfigurovány"
STR_DELETE_SERVER: "Smazat server"
STR_DELETE_CONFIRM: "Smazat tento server?"
STR_OPDS_SERVERS: "OPDS servery"

View File

@@ -380,3 +380,9 @@ STR_BOOK_REINDEXED: "Book reindexed"
STR_ACTION_FAILED: "Action failed"
STR_BACK_TO_BEGINNING: "Back to Beginning"
STR_CLOSE_MENU: "Close Menu"
STR_ADD_SERVER: "Add Server"
STR_SERVER_NAME: "Server Name"
STR_NO_SERVERS: "No OPDS servers configured"
STR_DELETE_SERVER: "Delete Server"
STR_DELETE_CONFIRM: "Delete this server?"
STR_OPDS_SERVERS: "OPDS Servers"

View File

@@ -344,3 +344,9 @@ STR_INDEXING_STATUS_ICON: "Icône barre d'état"
STR_SYNC_CLOCK: "Sync Clock"
STR_TIME_SYNCED: "Time synced!"
STR_AUTO_NTP_SYNC: "Auto Sync on Boot"
STR_ADD_SERVER: "Ajouter un serveur"
STR_SERVER_NAME: "Nom du serveur"
STR_NO_SERVERS: "Aucun serveur OPDS configuré"
STR_DELETE_SERVER: "Supprimer le serveur"
STR_DELETE_CONFIRM: "Supprimer ce serveur ?"
STR_OPDS_SERVERS: "Serveurs OPDS"

View File

@@ -344,3 +344,9 @@ STR_INDEXING_STATUS_ICON: "Statusleistensymbol"
STR_SYNC_CLOCK: "Sync Clock"
STR_TIME_SYNCED: "Time synced!"
STR_AUTO_NTP_SYNC: "Auto Sync on Boot"
STR_ADD_SERVER: "Server hinzufügen"
STR_SERVER_NAME: "Servername"
STR_NO_SERVERS: "Keine OPDS-Server konfiguriert"
STR_DELETE_SERVER: "Server löschen"
STR_DELETE_CONFIRM: "Diesen Server löschen?"
STR_OPDS_SERVERS: "OPDS-Server"

View File

@@ -344,3 +344,9 @@ STR_INDEXING_STATUS_ICON: "Ícone da barra"
STR_SYNC_CLOCK: "Sync Clock"
STR_TIME_SYNCED: "Time synced!"
STR_AUTO_NTP_SYNC: "Auto Sync on Boot"
STR_ADD_SERVER: "Adicionar servidor"
STR_SERVER_NAME: "Nome do servidor"
STR_NO_SERVERS: "Nenhum servidor OPDS configurado"
STR_DELETE_SERVER: "Excluir servidor"
STR_DELETE_CONFIRM: "Excluir este servidor?"
STR_OPDS_SERVERS: "Servidores OPDS"

View File

@@ -319,3 +319,9 @@ STR_OPDS_SERVER_URL: "URL server OPDS"
STR_SYNC_CLOCK: "Sync Clock"
STR_TIME_SYNCED: "Time synced!"
STR_AUTO_NTP_SYNC: "Auto Sync on Boot"
STR_ADD_SERVER: "Adaugă server"
STR_SERVER_NAME: "Numele serverului"
STR_NO_SERVERS: "Niciun server OPDS configurat"
STR_DELETE_SERVER: "Șterge serverul"
STR_DELETE_CONFIRM: "Ștergi acest server?"
STR_OPDS_SERVERS: "Servere OPDS"

View File

@@ -344,3 +344,9 @@ STR_INDEXING_STATUS_ICON: "Иконка в строке"
STR_SYNC_CLOCK: "Sync Clock"
STR_TIME_SYNCED: "Time synced!"
STR_AUTO_NTP_SYNC: "Auto Sync on Boot"
STR_ADD_SERVER: "Добавить сервер"
STR_SERVER_NAME: "Имя сервера"
STR_NO_SERVERS: "Нет настроенных серверов OPDS"
STR_DELETE_SERVER: "Удалить сервер"
STR_DELETE_CONFIRM: "Удалить этот сервер?"
STR_OPDS_SERVERS: "Серверы OPDS"

View File

@@ -344,3 +344,9 @@ STR_INDEXING_STATUS_ICON: "Icono barra estado"
STR_SYNC_CLOCK: "Sync Clock"
STR_TIME_SYNCED: "Time synced!"
STR_AUTO_NTP_SYNC: "Auto Sync on Boot"
STR_ADD_SERVER: "Añadir servidor"
STR_SERVER_NAME: "Nombre del servidor"
STR_NO_SERVERS: "No hay servidores OPDS configurados"
STR_DELETE_SERVER: "Eliminar servidor"
STR_DELETE_CONFIRM: "¿Eliminar este servidor?"
STR_OPDS_SERVERS: "Servidores OPDS"

View File

@@ -344,3 +344,9 @@ STR_INDEXING_STATUS_ICON: "Statusfältsikon"
STR_SYNC_CLOCK: "Sync Clock"
STR_TIME_SYNCED: "Time synced!"
STR_AUTO_NTP_SYNC: "Auto Sync on Boot"
STR_ADD_SERVER: "Lägg till server"
STR_SERVER_NAME: "Servernamn"
STR_NO_SERVERS: "Inga OPDS-servrar konfigurerade"
STR_DELETE_SERVER: "Ta bort server"
STR_DELETE_CONFIRM: "Ta bort denna server?"
STR_OPDS_SERVERS: "OPDS-servrar"

201
src/OpdsServerStore.cpp Normal file
View File

@@ -0,0 +1,201 @@
#include "OpdsServerStore.h"
#include <ArduinoJson.h>
#include <HalStorage.h>
#include <Logging.h>
#include <base64.h>
#include <esp_mac.h>
#include <mbedtls/base64.h>
#include <cstring>
#include "CrossPointSettings.h"
OpdsServerStore OpdsServerStore::instance;
namespace {
constexpr char OPDS_FILE_JSON[] = "/.crosspoint/opds.json";
constexpr size_t HW_KEY_LEN = 6;
const uint8_t* getHwKey() {
static uint8_t key[HW_KEY_LEN] = {};
static bool initialized = false;
if (!initialized) {
esp_efuse_mac_get_default(key);
initialized = true;
}
return key;
}
void xorTransform(std::string& data) {
const uint8_t* key = getHwKey();
for (size_t i = 0; i < data.size(); i++) {
data[i] ^= key[i % HW_KEY_LEN];
}
}
String obfuscateToBase64(const std::string& plaintext) {
if (plaintext.empty()) return "";
std::string temp = plaintext;
xorTransform(temp);
return base64::encode(reinterpret_cast<const uint8_t*>(temp.data()), temp.size());
}
std::string deobfuscateFromBase64(const char* encoded, bool* ok) {
if (encoded == nullptr || encoded[0] == '\0') {
if (ok) *ok = false;
return "";
}
if (ok) *ok = true;
size_t encodedLen = strlen(encoded);
size_t decodedLen = 0;
int ret = mbedtls_base64_decode(nullptr, 0, &decodedLen, reinterpret_cast<const unsigned char*>(encoded), encodedLen);
if (ret != 0 && ret != MBEDTLS_ERR_BASE64_BUFFER_TOO_SMALL) {
LOG_ERR("OPS", "Base64 decode size query failed (ret=%d)", ret);
if (ok) *ok = false;
return "";
}
std::string result(decodedLen, '\0');
ret = mbedtls_base64_decode(reinterpret_cast<unsigned char*>(&result[0]), decodedLen, &decodedLen,
reinterpret_cast<const unsigned char*>(encoded), encodedLen);
if (ret != 0) {
LOG_ERR("OPS", "Base64 decode failed (ret=%d)", ret);
if (ok) *ok = false;
return "";
}
result.resize(decodedLen);
xorTransform(result);
return result;
}
} // namespace
bool OpdsServerStore::saveToFile() const {
Storage.mkdir("/.crosspoint");
JsonDocument doc;
JsonArray arr = doc["servers"].to<JsonArray>();
for (const auto& server : servers) {
JsonObject obj = arr.add<JsonObject>();
obj["name"] = server.name;
obj["url"] = server.url;
obj["username"] = server.username;
obj["password_obf"] = obfuscateToBase64(server.password);
}
String json;
serializeJson(doc, json);
return Storage.writeFile(OPDS_FILE_JSON, json);
}
bool OpdsServerStore::loadFromFile() {
if (Storage.exists(OPDS_FILE_JSON)) {
String json = Storage.readFile(OPDS_FILE_JSON);
if (!json.isEmpty()) {
JsonDocument doc;
auto error = deserializeJson(doc, json.c_str());
if (error) {
LOG_ERR("OPS", "JSON parse error: %s", error.c_str());
return false;
}
servers.clear();
bool needsResave = false;
JsonArray arr = doc["servers"].as<JsonArray>();
for (JsonObject obj : arr) {
if (servers.size() >= MAX_SERVERS) break;
OpdsServer server;
server.name = obj["name"] | std::string("");
server.url = obj["url"] | std::string("");
server.username = obj["username"] | std::string("");
bool ok = false;
server.password = deobfuscateFromBase64(obj["password_obf"] | "", &ok);
if (!ok || server.password.empty()) {
server.password = obj["password"] | std::string("");
if (!server.password.empty()) needsResave = true;
}
servers.push_back(std::move(server));
}
LOG_DBG("OPS", "Loaded %zu OPDS servers from file", servers.size());
if (needsResave) {
LOG_DBG("OPS", "Resaving JSON with obfuscated passwords");
saveToFile();
}
return true;
}
}
// No opds.json found — attempt one-time migration from the legacy single-server
// fields in CrossPointSettings (opdsServerUrl/opdsUsername/opdsPassword).
if (migrateFromSettings()) {
LOG_DBG("OPS", "Migrated legacy OPDS settings");
return true;
}
return false;
}
bool OpdsServerStore::migrateFromSettings() {
if (strlen(SETTINGS.opdsServerUrl) == 0) {
return false;
}
OpdsServer server;
server.name = "OPDS Server";
server.url = SETTINGS.opdsServerUrl;
server.username = SETTINGS.opdsUsername;
server.password = SETTINGS.opdsPassword;
servers.push_back(std::move(server));
if (saveToFile()) {
SETTINGS.opdsServerUrl[0] = '\0';
SETTINGS.opdsUsername[0] = '\0';
SETTINGS.opdsPassword[0] = '\0';
SETTINGS.saveToFile();
LOG_DBG("OPS", "Migrated single-server OPDS config to opds.json");
return true;
}
servers.clear();
return false;
}
bool OpdsServerStore::addServer(const OpdsServer& server) {
if (servers.size() >= MAX_SERVERS) {
LOG_DBG("OPS", "Cannot add more servers, limit of %zu reached", MAX_SERVERS);
return false;
}
servers.push_back(server);
LOG_DBG("OPS", "Added server: %s", server.name.c_str());
return saveToFile();
}
bool OpdsServerStore::updateServer(size_t index, const OpdsServer& server) {
if (index >= servers.size()) {
return false;
}
servers[index] = server;
LOG_DBG("OPS", "Updated server: %s", server.name.c_str());
return saveToFile();
}
bool OpdsServerStore::removeServer(size_t index) {
if (index >= servers.size()) {
return false;
}
LOG_DBG("OPS", "Removed server: %s", servers[index].name.c_str());
servers.erase(servers.begin() + static_cast<ptrdiff_t>(index));
return saveToFile();
}
const OpdsServer* OpdsServerStore::getServer(size_t index) const {
if (index >= servers.size()) {
return nullptr;
}
return &servers[index];
}

51
src/OpdsServerStore.h Normal file
View File

@@ -0,0 +1,51 @@
#pragma once
#include <string>
#include <vector>
struct OpdsServer {
std::string name;
std::string url;
std::string username;
std::string password; // Plaintext in memory; obfuscated with hardware key on disk
};
/**
* Singleton class for storing OPDS server configurations on the SD card.
* Passwords are XOR-obfuscated with the device's unique hardware MAC address
* and base64-encoded before writing to JSON.
*/
class OpdsServerStore {
private:
static OpdsServerStore instance;
std::vector<OpdsServer> servers;
static constexpr size_t MAX_SERVERS = 8;
OpdsServerStore() = default;
public:
OpdsServerStore(const OpdsServerStore&) = delete;
OpdsServerStore& operator=(const OpdsServerStore&) = delete;
static OpdsServerStore& getInstance() { return instance; }
bool saveToFile() const;
bool loadFromFile();
bool addServer(const OpdsServer& server);
bool updateServer(size_t index, const OpdsServer& server);
bool removeServer(size_t index);
const std::vector<OpdsServer>& getServers() const { return servers; }
const OpdsServer* getServer(size_t index) const;
size_t getCount() const { return servers.size(); }
bool hasServers() const { return !servers.empty(); }
/**
* Migrate from legacy single-server settings in CrossPointSettings.
* Called once during first load if no opds.json exists.
*/
bool migrateFromSettings();
};
#define OPDS_STORE OpdsServerStore::getInstance()

View File

@@ -191,13 +191,5 @@ inline std::vector<SettingInfo> getSettingsList() {
KOREADER_STORE.saveToFile();
},
"koMatchMethod", StrId::STR_KOREADER_SYNC),
// --- OPDS Browser (web-only, uses CrossPointSettings char arrays) ---
SettingInfo::String(StrId::STR_OPDS_SERVER_URL, SETTINGS.opdsServerUrl, sizeof(SETTINGS.opdsServerUrl),
"opdsServerUrl", StrId::STR_OPDS_BROWSER),
SettingInfo::String(StrId::STR_USERNAME, SETTINGS.opdsUsername, sizeof(SETTINGS.opdsUsername), "opdsUsername",
StrId::STR_OPDS_BROWSER),
SettingInfo::String(StrId::STR_PASSWORD, SETTINGS.opdsPassword, sizeof(SETTINGS.opdsPassword), "opdsPassword",
StrId::STR_OPDS_BROWSER),
};
}

View File

@@ -7,7 +7,6 @@
#include <OpdsStream.h>
#include <WiFi.h>
#include "CrossPointSettings.h"
#include "MappedInputManager.h"
#include "activities/network/WifiSelectionActivity.h"
#include "components/UITheme.h"
@@ -142,7 +141,8 @@ void OpdsBookBrowserActivity::render(Activity::RenderLock&&) {
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
renderer.drawCenteredText(UI_12_FONT_ID, 15, tr(STR_OPDS_BROWSER), true, EpdFontFamily::BOLD);
const char* headerTitle = server.name.empty() ? tr(STR_OPDS_BROWSER) : server.name.c_str();
renderer.drawCenteredText(UI_12_FONT_ID, 15, headerTitle, true, EpdFontFamily::BOLD);
if (state == BrowserState::CHECK_WIFI) {
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, statusMessage.c_str());
@@ -171,7 +171,9 @@ void OpdsBookBrowserActivity::render(Activity::RenderLock&&) {
if (state == BrowserState::DOWNLOADING) {
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 40, tr(STR_DOWNLOADING));
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 10, statusMessage.c_str());
const auto maxWidth = pageWidth - 40;
auto title = renderer.truncatedText(UI_10_FONT_ID, statusMessage.c_str(), maxWidth);
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 10, title.c_str());
if (downloadTotal > 0) {
const int barWidth = pageWidth - 100;
constexpr int barHeight = 20;
@@ -225,22 +227,21 @@ void OpdsBookBrowserActivity::render(Activity::RenderLock&&) {
}
void OpdsBookBrowserActivity::fetchFeed(const std::string& path) {
const char* serverUrl = SETTINGS.opdsServerUrl;
if (strlen(serverUrl) == 0) {
if (server.url.empty()) {
state = BrowserState::ERROR;
errorMessage = tr(STR_NO_SERVER_URL);
requestUpdate();
return;
}
std::string url = UrlUtils::buildUrl(serverUrl, path);
std::string url = UrlUtils::buildUrl(server.url, path);
LOG_DBG("OPDS", "Fetching: %s", url.c_str());
OpdsParser parser;
{
OpdsParserStream stream{parser};
if (!HttpDownloader::fetchUrl(url, stream)) {
if (!HttpDownloader::fetchUrl(url, stream, server.username, server.password)) {
state = BrowserState::ERROR;
errorMessage = tr(STR_FETCH_FEED_FAILED);
requestUpdate();
@@ -311,7 +312,7 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) {
requestUpdate();
// Build full download URL
std::string downloadUrl = UrlUtils::buildUrl(SETTINGS.opdsServerUrl, book.href);
std::string downloadUrl = UrlUtils::buildUrl(server.url, book.href);
// Create sanitized filename: "Title - Author.epub" or just "Title.epub" if no author
std::string baseName = book.title;
@@ -322,12 +323,14 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) {
LOG_DBG("OPDS", "Downloading: %s -> %s", downloadUrl.c_str(), filename.c_str());
const auto result =
HttpDownloader::downloadToFile(downloadUrl, filename, [this](const size_t downloaded, const size_t total) {
const auto result = HttpDownloader::downloadToFile(
downloadUrl, filename,
[this](const size_t downloaded, const size_t total) {
downloadProgress = downloaded;
downloadTotal = total;
requestUpdate();
});
},
server.username, server.password);
if (result == HttpDownloader::OK) {
LOG_DBG("OPDS", "Download complete: %s", filename.c_str());

View File

@@ -6,6 +6,7 @@
#include <vector>
#include "../ActivityWithSubactivity.h"
#include "OpdsServerStore.h"
#include "util/ButtonNavigator.h"
/**
@@ -25,8 +26,8 @@ class OpdsBookBrowserActivity final : public ActivityWithSubactivity {
};
explicit OpdsBookBrowserActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::function<void()>& onGoHome)
: ActivityWithSubactivity("OpdsBookBrowser", renderer, mappedInput), onGoHome(onGoHome) {}
const std::function<void()>& onGoHome, const OpdsServer& server)
: ActivityWithSubactivity("OpdsBookBrowser", renderer, mappedInput), onGoHome(onGoHome), server(server) {}
void onEnter() override;
void onExit() override;
@@ -46,6 +47,7 @@ class OpdsBookBrowserActivity final : public ActivityWithSubactivity {
size_t downloadTotal = 0;
const std::function<void()> onGoHome;
OpdsServer server; // Copied at construction — safe even if the store changes during browsing
void checkAndConnectWifi();
void launchWifiSelection();

View File

@@ -17,6 +17,7 @@
#include "CrossPointSettings.h"
#include "CrossPointState.h"
#include "MappedInputManager.h"
#include "OpdsServerStore.h"
#include "RecentBooksStore.h"
#include "components/UITheme.h"
#include "fontIds.h"
@@ -28,7 +29,7 @@ int HomeActivity::getMenuItemCount() const {
if (!recentBooks.empty()) {
count += recentBooks.size();
}
if (hasOpdsUrl) {
if (hasOpdsServers) {
count++;
}
return count;
@@ -128,8 +129,7 @@ void HomeActivity::loadRecentCovers(int coverHeight) {
void HomeActivity::onEnter() {
ActivityWithSubactivity::onEnter();
// Check if OPDS browser URL is configured
hasOpdsUrl = strlen(SETTINGS.opdsServerUrl) > 0;
hasOpdsServers = OPDS_STORE.hasServers();
selectorIndex = 0;
@@ -238,7 +238,7 @@ void HomeActivity::loop() {
int menuSelectedIndex = selectorIndex - static_cast<int>(recentBooks.size());
const int myLibraryIdx = idx++;
const int recentsIdx = idx++;
const int opdsLibraryIdx = hasOpdsUrl ? idx++ : -1;
const int opdsLibraryIdx = hasOpdsServers ? idx++ : -1;
const int fileTransferIdx = idx++;
const int settingsIdx = idx;
@@ -277,7 +277,7 @@ void HomeActivity::render(Activity::RenderLock&&) {
tr(STR_SETTINGS_TITLE)};
std::vector<UIIcon> menuIcons = {Folder, Recent, Transfer, Settings};
if (hasOpdsUrl) {
if (hasOpdsServers) {
// Insert OPDS Browser after My Library
menuItems.insert(menuItems.begin() + 2, tr(STR_OPDS_BROWSER));
menuIcons.insert(menuIcons.begin() + 2, Library);

View File

@@ -15,7 +15,7 @@ class HomeActivity final : public ActivityWithSubactivity {
bool recentsLoading = false;
bool recentsLoaded = false;
bool firstRenderDone = false;
bool hasOpdsUrl = false;
bool hasOpdsServers = false;
bool coverRendered = false; // Track if cover has been rendered once
bool coverBufferStored = false; // Track if cover buffer is stored
uint8_t* coverBuffer = nullptr; // HomeActivity's own buffer for cover image

View File

@@ -1,149 +0,0 @@
#include "CalibreSettingsActivity.h"
#include <GfxRenderer.h>
#include <I18n.h>
#include <cstring>
#include "CrossPointSettings.h"
#include "MappedInputManager.h"
#include "activities/util/KeyboardEntryActivity.h"
#include "components/UITheme.h"
#include "fontIds.h"
namespace {
constexpr int MENU_ITEMS = 3;
const StrId menuNames[MENU_ITEMS] = {StrId::STR_CALIBRE_WEB_URL, StrId::STR_USERNAME, StrId::STR_PASSWORD};
} // namespace
void CalibreSettingsActivity::onEnter() {
ActivityWithSubactivity::onEnter();
selectedIndex = 0;
requestUpdate();
}
void CalibreSettingsActivity::onExit() { ActivityWithSubactivity::onExit(); }
void CalibreSettingsActivity::loop() {
if (subActivity) {
subActivity->loop();
return;
}
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
onBack();
return;
}
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
handleSelection();
return;
}
// Handle navigation
buttonNavigator.onNext([this] {
selectedIndex = (selectedIndex + 1) % MENU_ITEMS;
requestUpdate();
});
buttonNavigator.onPrevious([this] {
selectedIndex = (selectedIndex + MENU_ITEMS - 1) % MENU_ITEMS;
requestUpdate();
});
}
void CalibreSettingsActivity::handleSelection() {
if (selectedIndex == 0) {
// OPDS Server URL
exitActivity();
enterNewActivity(new KeyboardEntryActivity(
renderer, mappedInput, tr(STR_CALIBRE_WEB_URL), SETTINGS.opdsServerUrl,
127, // maxLength
false, // not password
[this](const std::string& url) {
strncpy(SETTINGS.opdsServerUrl, url.c_str(), sizeof(SETTINGS.opdsServerUrl) - 1);
SETTINGS.opdsServerUrl[sizeof(SETTINGS.opdsServerUrl) - 1] = '\0';
SETTINGS.saveToFile();
exitActivity();
requestUpdate();
},
[this]() {
exitActivity();
requestUpdate();
}));
} else if (selectedIndex == 1) {
// Username
exitActivity();
enterNewActivity(new KeyboardEntryActivity(
renderer, mappedInput, tr(STR_USERNAME), SETTINGS.opdsUsername,
63, // maxLength
false, // not password
[this](const std::string& username) {
strncpy(SETTINGS.opdsUsername, username.c_str(), sizeof(SETTINGS.opdsUsername) - 1);
SETTINGS.opdsUsername[sizeof(SETTINGS.opdsUsername) - 1] = '\0';
SETTINGS.saveToFile();
exitActivity();
requestUpdate();
},
[this]() {
exitActivity();
requestUpdate();
}));
} else if (selectedIndex == 2) {
// Password
exitActivity();
enterNewActivity(new KeyboardEntryActivity(
renderer, mappedInput, tr(STR_PASSWORD), SETTINGS.opdsPassword,
63, // maxLength
false, // not password mode
[this](const std::string& password) {
strncpy(SETTINGS.opdsPassword, password.c_str(), sizeof(SETTINGS.opdsPassword) - 1);
SETTINGS.opdsPassword[sizeof(SETTINGS.opdsPassword) - 1] = '\0';
SETTINGS.saveToFile();
exitActivity();
requestUpdate();
},
[this]() {
exitActivity();
requestUpdate();
}));
}
}
void CalibreSettingsActivity::render(Activity::RenderLock&&) {
renderer.clearScreen();
auto metrics = UITheme::getInstance().getMetrics();
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_OPDS_BROWSER));
GUI.drawSubHeader(renderer, Rect{0, metrics.topPadding + metrics.headerHeight, pageWidth, metrics.tabBarHeight},
tr(STR_CALIBRE_URL_HINT));
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing + metrics.tabBarHeight;
const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing * 2;
GUI.drawList(
renderer, Rect{0, contentTop, pageWidth, contentHeight}, static_cast<int>(MENU_ITEMS),
static_cast<int>(selectedIndex), [](int index) { return std::string(I18N.get(menuNames[index])); }, nullptr,
nullptr,
[this](int index) {
// Draw status for each setting
if (index == 0) {
return (strlen(SETTINGS.opdsServerUrl) > 0) ? std::string(SETTINGS.opdsServerUrl)
: std::string(tr(STR_NOT_SET));
} else if (index == 1) {
return (strlen(SETTINGS.opdsUsername) > 0) ? std::string(SETTINGS.opdsUsername)
: std::string(tr(STR_NOT_SET));
} else if (index == 2) {
return (strlen(SETTINGS.opdsPassword) > 0) ? std::string("******") : std::string(tr(STR_NOT_SET));
}
return std::string(tr(STR_NOT_SET));
},
true);
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();
}

View File

@@ -1,29 +0,0 @@
#pragma once
#include <functional>
#include "activities/ActivityWithSubactivity.h"
#include "util/ButtonNavigator.h"
/**
* Submenu for OPDS Browser settings.
* Shows OPDS Server URL and HTTP authentication options.
*/
class CalibreSettingsActivity final : public ActivityWithSubactivity {
public:
explicit CalibreSettingsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::function<void()>& onBack)
: ActivityWithSubactivity("CalibreSettings", renderer, mappedInput), onBack(onBack) {}
void onEnter() override;
void onExit() override;
void loop() override;
void render(Activity::RenderLock&&) override;
private:
ButtonNavigator buttonNavigator;
size_t selectedIndex = 0;
const std::function<void()> onBack;
void handleSelection();
};

View File

@@ -0,0 +1,131 @@
#include "OpdsServerListActivity.h"
#include <GfxRenderer.h>
#include <I18n.h>
#include "MappedInputManager.h"
#include "OpdsServerStore.h"
#include "OpdsSettingsActivity.h"
#include "components/UITheme.h"
#include "fontIds.h"
int OpdsServerListActivity::getItemCount() const {
int count = static_cast<int>(OPDS_STORE.getCount());
// In settings mode, append a virtual "Add Server" item; in picker mode, only show real servers
if (!isPickerMode()) {
count++;
}
return count;
}
void OpdsServerListActivity::onEnter() {
ActivityWithSubactivity::onEnter();
// Reload from disk in case servers were added/removed by a subactivity or the web UI
OPDS_STORE.loadFromFile();
selectedIndex = 0;
requestUpdate();
}
void OpdsServerListActivity::onExit() { ActivityWithSubactivity::onExit(); }
void OpdsServerListActivity::loop() {
if (subActivity) {
subActivity->loop();
return;
}
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
onBack();
return;
}
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
handleSelection();
return;
}
const int itemCount = getItemCount();
if (itemCount > 0) {
buttonNavigator.onNext([this, itemCount] {
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, itemCount);
requestUpdate();
});
buttonNavigator.onPrevious([this, itemCount] {
selectedIndex = ButtonNavigator::previousIndex(selectedIndex, itemCount);
requestUpdate();
});
}
}
void OpdsServerListActivity::handleSelection() {
const auto serverCount = static_cast<int>(OPDS_STORE.getCount());
if (isPickerMode()) {
// Picker mode: selecting a server triggers the callback instead of opening the editor
if (selectedIndex < serverCount) {
onServerSelected(static_cast<size_t>(selectedIndex));
}
return;
}
// Settings mode: open editor for selected server, or create a new one
auto onEditDone = [this] {
exitActivity();
selectedIndex = 0;
requestUpdate();
};
if (selectedIndex < serverCount) {
exitActivity();
enterNewActivity(new OpdsSettingsActivity(renderer, mappedInput, onEditDone, selectedIndex));
} else {
exitActivity();
enterNewActivity(new OpdsSettingsActivity(renderer, mappedInput, onEditDone, -1));
}
}
void OpdsServerListActivity::render(Activity::RenderLock&&) {
renderer.clearScreen();
const auto& metrics = UITheme::getInstance().getMetrics();
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_OPDS_SERVERS));
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing * 2;
const int itemCount = getItemCount();
if (itemCount == 0) {
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, tr(STR_NO_SERVERS));
} else {
const auto& servers = OPDS_STORE.getServers();
const auto serverCount = static_cast<int>(servers.size());
// Primary label: server name (falling back to URL if unnamed).
// Secondary label: server URL (shown as subtitle when name is set).
GUI.drawList(
renderer, Rect{0, contentTop, pageWidth, contentHeight}, itemCount, selectedIndex,
[&servers, serverCount](int index) {
if (index < serverCount) {
const auto& server = servers[index];
return server.name.empty() ? server.url : server.name;
}
return std::string(I18n::getInstance().get(StrId::STR_ADD_SERVER));
},
[&servers, serverCount](int index) {
if (index < serverCount && !servers[index].name.empty()) {
return servers[index].url;
}
return std::string("");
});
}
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();
}

View File

@@ -0,0 +1,41 @@
#pragma once
#include <functional>
#include "activities/ActivityWithSubactivity.h"
#include "util/ButtonNavigator.h"
/**
* Activity showing the list of configured OPDS servers.
* Allows adding new servers and editing/deleting existing ones.
* Used from Settings and also as a server picker from the home screen.
*/
class OpdsServerListActivity final : public ActivityWithSubactivity {
public:
using OnServerSelected = std::function<void(size_t serverIndex)>;
/**
* @param onBack Called when user presses Back
* @param onServerSelected If set, acts as a picker: selecting a server calls this instead of opening editor.
*/
explicit OpdsServerListActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::function<void()>& onBack, OnServerSelected onServerSelected = nullptr)
: ActivityWithSubactivity("OpdsServerList", renderer, mappedInput),
onBack(onBack),
onServerSelected(std::move(onServerSelected)) {}
void onEnter() override;
void onExit() override;
void loop() override;
void render(Activity::RenderLock&&) override;
private:
ButtonNavigator buttonNavigator;
int selectedIndex = 0;
const std::function<void()> onBack;
OnServerSelected onServerSelected;
bool isPickerMode() const { return onServerSelected != nullptr; }
int getItemCount() const;
void handleSelection();
};

View File

@@ -0,0 +1,199 @@
#include "OpdsSettingsActivity.h"
#include <GfxRenderer.h>
#include <I18n.h>
#include <cstring>
#include "MappedInputManager.h"
#include "OpdsServerStore.h"
#include "activities/util/KeyboardEntryActivity.h"
#include "components/UITheme.h"
#include "fontIds.h"
namespace {
// Editable fields: Name, URL, Username, Password.
// Existing servers also show a Delete option (BASE_ITEMS + 1).
constexpr int BASE_ITEMS = 4;
} // namespace
int OpdsSettingsActivity::getMenuItemCount() const {
return isNewServer ? BASE_ITEMS : BASE_ITEMS + 1; // +1 for Delete
}
void OpdsSettingsActivity::onEnter() {
ActivityWithSubactivity::onEnter();
selectedIndex = 0;
isNewServer = (serverIndex < 0);
if (!isNewServer) {
const auto* server = OPDS_STORE.getServer(static_cast<size_t>(serverIndex));
if (server) {
editServer = *server;
} else {
// Server was deleted between navigation and entering this screen — treat as new
isNewServer = true;
serverIndex = -1;
}
}
requestUpdate();
}
void OpdsSettingsActivity::onExit() { ActivityWithSubactivity::onExit(); }
void OpdsSettingsActivity::loop() {
if (subActivity) {
subActivity->loop();
return;
}
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
onBack();
return;
}
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
handleSelection();
return;
}
const int menuItems = getMenuItemCount();
buttonNavigator.onNext([this, menuItems] {
selectedIndex = (selectedIndex + 1) % menuItems;
requestUpdate();
});
buttonNavigator.onPrevious([this, menuItems] {
selectedIndex = (selectedIndex + menuItems - 1) % menuItems;
requestUpdate();
});
}
void OpdsSettingsActivity::saveServer() {
if (isNewServer) {
OPDS_STORE.addServer(editServer);
// After the first field is saved, promote to an existing server so
// subsequent field edits update in-place rather than creating duplicates.
isNewServer = false;
serverIndex = static_cast<int>(OPDS_STORE.getCount()) - 1;
} else {
OPDS_STORE.updateServer(static_cast<size_t>(serverIndex), editServer);
}
}
void OpdsSettingsActivity::handleSelection() {
if (selectedIndex == 0) {
// Server Name
exitActivity();
enterNewActivity(new KeyboardEntryActivity(
renderer, mappedInput, tr(STR_SERVER_NAME), editServer.name, 63, false,
[this](const std::string& name) {
editServer.name = name;
saveServer();
exitActivity();
requestUpdate();
},
[this]() {
exitActivity();
requestUpdate();
}));
} else if (selectedIndex == 1) {
// Server URL
exitActivity();
enterNewActivity(new KeyboardEntryActivity(
renderer, mappedInput, tr(STR_OPDS_SERVER_URL), editServer.url, 127, false,
[this](const std::string& url) {
editServer.url = url;
saveServer();
exitActivity();
requestUpdate();
},
[this]() {
exitActivity();
requestUpdate();
}));
} else if (selectedIndex == 2) {
// Username
exitActivity();
enterNewActivity(new KeyboardEntryActivity(
renderer, mappedInput, tr(STR_USERNAME), editServer.username, 63, false,
[this](const std::string& username) {
editServer.username = username;
saveServer();
exitActivity();
requestUpdate();
},
[this]() {
exitActivity();
requestUpdate();
}));
} else if (selectedIndex == 3) {
// Password
exitActivity();
enterNewActivity(new KeyboardEntryActivity(
renderer, mappedInput, tr(STR_PASSWORD), editServer.password, 63, false,
[this](const std::string& password) {
editServer.password = password;
saveServer();
exitActivity();
requestUpdate();
},
[this]() {
exitActivity();
requestUpdate();
}));
} else if (selectedIndex == 4 && !isNewServer) {
// Delete server
OPDS_STORE.removeServer(static_cast<size_t>(serverIndex));
onBack();
}
}
void OpdsSettingsActivity::render(Activity::RenderLock&&) {
renderer.clearScreen();
const auto& metrics = UITheme::getInstance().getMetrics();
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
const char* header = isNewServer ? tr(STR_ADD_SERVER) : tr(STR_OPDS_BROWSER);
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, header);
GUI.drawSubHeader(renderer, Rect{0, metrics.topPadding + metrics.headerHeight, pageWidth, metrics.tabBarHeight},
tr(STR_CALIBRE_URL_HINT));
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing + metrics.tabBarHeight;
const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing * 2;
const int menuItems = getMenuItemCount();
const StrId fieldNames[] = {StrId::STR_SERVER_NAME, StrId::STR_OPDS_SERVER_URL, StrId::STR_USERNAME,
StrId::STR_PASSWORD};
GUI.drawList(
renderer, Rect{0, contentTop, pageWidth, contentHeight}, menuItems, static_cast<int>(selectedIndex),
[this, &fieldNames](int index) {
if (index < BASE_ITEMS) {
return std::string(I18N.get(fieldNames[index]));
}
return std::string(tr(STR_DELETE_SERVER));
},
nullptr, nullptr,
[this](int index) {
if (index == 0) {
return editServer.name.empty() ? std::string(tr(STR_NOT_SET)) : editServer.name;
} else if (index == 1) {
return editServer.url.empty() ? std::string(tr(STR_NOT_SET)) : editServer.url;
} else if (index == 2) {
return editServer.username.empty() ? std::string(tr(STR_NOT_SET)) : editServer.username;
} else if (index == 3) {
return editServer.password.empty() ? std::string(tr(STR_NOT_SET)) : std::string("******");
}
return std::string("");
},
true);
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();
}

View File

@@ -0,0 +1,40 @@
#pragma once
#include <functional>
#include "OpdsServerStore.h"
#include "activities/ActivityWithSubactivity.h"
#include "util/ButtonNavigator.h"
/**
* Edit screen for a single OPDS server.
* Shows Name, URL, Username, Password fields and a Delete option.
* Used for both adding new servers and editing existing ones.
*/
class OpdsSettingsActivity final : public ActivityWithSubactivity {
public:
/**
* @param serverIndex Index into OpdsServerStore, or -1 for a new server
*/
explicit OpdsSettingsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::function<void()>& onBack, int serverIndex = -1)
: ActivityWithSubactivity("OpdsSettings", renderer, mappedInput), onBack(onBack), serverIndex(serverIndex) {}
void onEnter() override;
void onExit() override;
void loop() override;
void render(Activity::RenderLock&&) override;
private:
ButtonNavigator buttonNavigator;
size_t selectedIndex = 0;
const std::function<void()> onBack;
int serverIndex;
OpdsServer editServer;
bool isNewServer = false;
int getMenuItemCount() const;
void handleSelection();
void saveServer();
};

View File

@@ -7,7 +7,7 @@
#include <cstdlib>
#include "ButtonRemapActivity.h"
#include "CalibreSettingsActivity.h"
#include "OpdsServerListActivity.h"
#include "ClearCacheActivity.h"
#include "CrossPointSettings.h"
#include "KOReaderSettingsActivity.h"
@@ -202,7 +202,7 @@ void SettingsActivity::toggleCurrentSetting() {
enterSubActivity(new KOReaderSettingsActivity(renderer, mappedInput, onComplete));
break;
case SettingAction::OPDSBrowser:
enterSubActivity(new CalibreSettingsActivity(renderer, mappedInput, onComplete));
enterSubActivity(new OpdsServerListActivity(renderer, mappedInput, onComplete));
break;
case SettingAction::Network:
enterSubActivity(new WifiSelectionActivity(renderer, mappedInput, onCompleteBool, false));

View File

@@ -19,6 +19,7 @@
#include "CrossPointState.h"
#include "KOReaderCredentialStore.h"
#include "MappedInputManager.h"
#include "OpdsServerStore.h"
#include "RecentBooksStore.h"
#include "activities/boot_sleep/BootActivity.h"
#include "activities/boot_sleep/SleepActivity.h"
@@ -28,6 +29,7 @@
#include "activities/home/RecentBooksActivity.h"
#include "activities/network/CrossPointWebServerActivity.h"
#include "activities/reader/ReaderActivity.h"
#include "activities/settings/OpdsServerListActivity.h"
#include "activities/settings/SettingsActivity.h"
#include "activities/util/FullScreenMessageActivity.h"
#include "components/UITheme.h"
@@ -261,7 +263,18 @@ void onGoToMyLibraryWithPath(const std::string& path, bool initialSkipRelease) {
void onGoToBrowser() {
exitActivity();
enterNewActivity(new OpdsBookBrowserActivity(renderer, mappedInputManager, onGoHome));
const auto& servers = OPDS_STORE.getServers();
if (servers.size() == 1) {
enterNewActivity(new OpdsBookBrowserActivity(renderer, mappedInputManager, onGoHome, servers[0]));
} else {
enterNewActivity(new OpdsServerListActivity(renderer, mappedInputManager, onGoHome, [](size_t serverIndex) {
const auto* server = OPDS_STORE.getServer(serverIndex);
if (server) {
exitActivity();
enterNewActivity(new OpdsBookBrowserActivity(renderer, mappedInputManager, onGoHome, *server));
}
}));
}
}
void onGoHome() {
@@ -343,6 +356,7 @@ void setup() {
I18N.loadSettings();
KOREADER_STORE.loadFromFile();
OPDS_STORE.loadFromFile();
BootNtpSync::start();
UITheme::getInstance().reload();
ButtonNavigator::setMappedInputManager(mappedInputManager);

View File

@@ -11,6 +11,7 @@
#include <algorithm>
#include "CrossPointSettings.h"
#include "OpdsServerStore.h"
#include "SettingsList.h"
#include "html/FilesPageHtml.generated.h"
#include "html/HomePageHtml.generated.h"
@@ -156,6 +157,11 @@ void CrossPointWebServer::begin() {
server->on("/api/settings", HTTP_GET, [this] { handleGetSettings(); });
server->on("/api/settings", HTTP_POST, [this] { handlePostSettings(); });
// OPDS server management endpoints
server->on("/api/opds", HTTP_GET, [this] { handleGetOpdsServers(); });
server->on("/api/opds", HTTP_POST, [this] { handlePostOpdsServer(); });
server->on("/api/opds/delete", HTTP_POST, [this] { handleDeleteOpdsServer(); });
server->onNotFound([this] { handleNotFound(); });
LOG_DBG("WEB", "[MEM] Free heap after route setup: %d bytes", ESP.getFreeHeap());
@@ -1157,6 +1163,116 @@ void CrossPointWebServer::handlePostSettings() {
server->send(200, "text/plain", String("Applied ") + String(applied) + " setting(s)");
}
// ---- OPDS Server Management API ----
void CrossPointWebServer::handleGetOpdsServers() const {
const auto& servers = OPDS_STORE.getServers();
server->setContentLength(CONTENT_LENGTH_UNKNOWN);
server->send(200, "application/json", "");
server->sendContent("[");
constexpr size_t outputSize = 512;
char output[outputSize];
bool first = true;
for (size_t i = 0; i < servers.size(); i++) {
JsonDocument doc;
doc["name"] = servers[i].name;
doc["url"] = servers[i].url;
doc["username"] = servers[i].username;
doc["hasPassword"] = !servers[i].password.empty();
const size_t written = serializeJson(doc, output, outputSize);
if (written >= outputSize) continue;
if (!first) server->sendContent(",");
server->sendContent(output);
first = false;
}
server->sendContent("]");
LOG_DBG("WEB", "Served OPDS servers API (%zu servers)", servers.size());
}
void CrossPointWebServer::handlePostOpdsServer() {
if (!server->hasArg("plain")) {
server->send(400, "text/plain", "Missing JSON body");
return;
}
const String body = server->arg("plain");
JsonDocument doc;
const DeserializationError err = deserializeJson(doc, body);
if (err) {
server->send(400, "text/plain", String("Invalid JSON: ") + err.c_str());
return;
}
OpdsServer opdsServer;
opdsServer.name = doc["name"] | std::string("");
opdsServer.url = doc["url"] | std::string("");
opdsServer.username = doc["username"] | std::string("");
bool hasPasswordField = doc["password"].is<const char*>() || doc["password"].is<std::string>();
std::string password = doc["password"] | std::string("");
if (doc["index"].is<int>()) {
int idx = doc["index"].as<int>();
if (idx < 0 || idx >= static_cast<int>(OPDS_STORE.getCount())) {
server->send(400, "text/plain", "Invalid server index");
return;
}
if (!hasPasswordField) {
const auto* existing = OPDS_STORE.getServer(static_cast<size_t>(idx));
if (existing) password = existing->password;
}
opdsServer.password = password;
OPDS_STORE.updateServer(static_cast<size_t>(idx), opdsServer);
LOG_DBG("WEB", "Updated OPDS server at index %d", idx);
} else {
opdsServer.password = password;
if (!OPDS_STORE.addServer(opdsServer)) {
server->send(400, "text/plain", "Cannot add server (limit reached)");
return;
}
LOG_DBG("WEB", "Added new OPDS server: %s", opdsServer.name.c_str());
}
server->send(200, "text/plain", "OK");
}
// Uses POST (not HTTP DELETE) because ESP32 WebServer doesn't support DELETE with body.
void CrossPointWebServer::handleDeleteOpdsServer() {
if (!server->hasArg("plain")) {
server->send(400, "text/plain", "Missing JSON body");
return;
}
const String body = server->arg("plain");
JsonDocument doc;
const DeserializationError err = deserializeJson(doc, body);
if (err) {
server->send(400, "text/plain", String("Invalid JSON: ") + err.c_str());
return;
}
if (!doc["index"].is<int>()) {
server->send(400, "text/plain", "Missing index");
return;
}
int idx = doc["index"].as<int>();
if (idx < 0 || idx >= static_cast<int>(OPDS_STORE.getCount())) {
server->send(400, "text/plain", "Invalid server index");
return;
}
OPDS_STORE.removeServer(static_cast<size_t>(idx));
LOG_DBG("WEB", "Deleted OPDS server at index %d", idx);
server->send(200, "text/plain", "OK");
}
// WebSocket callback trampoline
void CrossPointWebServer::wsEventCallback(uint8_t num, WStype_t type, uint8_t* payload, size_t length) {
if (wsInstance) {

View File

@@ -105,4 +105,9 @@ class CrossPointWebServer {
void handleSettingsPage() const;
void handleGetSettings() const;
void handlePostSettings();
// OPDS server handlers
void handleGetOpdsServers() const;
void handlePostOpdsServer();
void handleDeleteOpdsServer();
};

View File

@@ -9,12 +9,49 @@
#include <cstring>
#include <memory>
#include <utility>
#include "CrossPointSettings.h"
#include "util/UrlUtils.h"
bool HttpDownloader::fetchUrl(const std::string& url, Stream& outContent) {
// Use WiFiClientSecure for HTTPS, regular WiFiClient for HTTP
namespace {
class FileWriteStream final : public Stream {
public:
FileWriteStream(FsFile& file, size_t total, HttpDownloader::ProgressCallback progress)
: file_(file), total_(total), progress_(std::move(progress)) {}
size_t write(uint8_t byte) override { return write(&byte, 1); }
size_t write(const uint8_t* buffer, size_t size) override {
const size_t written = file_.write(buffer, size);
if (written != size) {
writeOk_ = false;
}
downloaded_ += written;
if (progress_ && total_ > 0) {
progress_(downloaded_, total_);
}
return written;
}
int available() override { return 0; }
int read() override { return -1; }
int peek() override { return -1; }
void flush() override { file_.flush(); }
size_t downloaded() const { return downloaded_; }
bool ok() const { return writeOk_; }
private:
FsFile& file_;
size_t total_;
size_t downloaded_ = 0;
bool writeOk_ = true;
HttpDownloader::ProgressCallback progress_;
};
} // namespace
bool HttpDownloader::fetchUrl(const std::string& url, Stream& outContent, const std::string& username,
const std::string& password) {
std::unique_ptr<WiFiClient> client;
if (UrlUtils::isHttpsUrl(url)) {
auto* secureClient = new WiFiClientSecure();
@@ -31,9 +68,8 @@ bool HttpDownloader::fetchUrl(const std::string& url, Stream& outContent) {
http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
http.addHeader("User-Agent", "CrossPoint-ESP32-" CROSSPOINT_VERSION);
// Add Basic HTTP auth if credentials are configured
if (strlen(SETTINGS.opdsUsername) > 0 && strlen(SETTINGS.opdsPassword) > 0) {
std::string credentials = std::string(SETTINGS.opdsUsername) + ":" + SETTINGS.opdsPassword;
if (!username.empty() && !password.empty()) {
std::string credentials = username + ":" + password;
String encoded = base64::encode(credentials.c_str());
http.addHeader("Authorization", "Basic " + encoded);
}
@@ -53,9 +89,10 @@ bool HttpDownloader::fetchUrl(const std::string& url, Stream& outContent) {
return true;
}
bool HttpDownloader::fetchUrl(const std::string& url, std::string& outContent) {
bool HttpDownloader::fetchUrl(const std::string& url, std::string& outContent, const std::string& username,
const std::string& password) {
StreamString stream;
if (!fetchUrl(url, stream)) {
if (!fetchUrl(url, stream, username, password)) {
return false;
}
outContent = stream.c_str();
@@ -63,8 +100,8 @@ bool HttpDownloader::fetchUrl(const std::string& url, std::string& outContent) {
}
HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string& url, const std::string& destPath,
ProgressCallback progress) {
// Use WiFiClientSecure for HTTPS, regular WiFiClient for HTTP
ProgressCallback progress, const std::string& username,
const std::string& password) {
std::unique_ptr<WiFiClient> client;
if (UrlUtils::isHttpsUrl(url)) {
auto* secureClient = new WiFiClientSecure();
@@ -82,9 +119,8 @@ HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string&
http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
http.addHeader("User-Agent", "CrossPoint-ESP32-" CROSSPOINT_VERSION);
// Add Basic HTTP auth if credentials are configured
if (strlen(SETTINGS.opdsUsername) > 0 && strlen(SETTINGS.opdsPassword) > 0) {
std::string credentials = std::string(SETTINGS.opdsUsername) + ":" + SETTINGS.opdsPassword;
if (!username.empty() && !password.empty()) {
std::string credentials = username + ":" + password;
String encoded = base64::encode(credentials.c_str());
http.addHeader("Authorization", "Basic " + encoded);
}
@@ -96,8 +132,13 @@ HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string&
return HTTP_ERROR;
}
const size_t contentLength = http.getSize();
LOG_DBG("HTTP", "Content-Length: %zu", contentLength);
const int64_t reportedLength = http.getSize();
const size_t contentLength = reportedLength > 0 ? static_cast<size_t>(reportedLength) : 0;
if (contentLength > 0) {
LOG_DBG("HTTP", "Content-Length: %zu", contentLength);
} else {
LOG_DBG("HTTP", "Content-Length: unknown");
}
// Remove existing file if present
if (Storage.exists(destPath.c_str())) {
@@ -112,56 +153,29 @@ HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string&
return FILE_ERROR;
}
// Get the stream for chunked reading
WiFiClient* stream = http.getStreamPtr();
if (!stream) {
LOG_ERR("HTTP", "Failed to get stream");
file.close();
Storage.remove(destPath.c_str());
http.end();
return HTTP_ERROR;
}
// Download in chunks
uint8_t buffer[DOWNLOAD_CHUNK_SIZE];
size_t downloaded = 0;
// Let HTTPClient handle chunked decoding and stream body bytes into the file.
const size_t total = contentLength > 0 ? contentLength : 0;
while (http.connected() && (contentLength == 0 || downloaded < contentLength)) {
const size_t available = stream->available();
if (available == 0) {
delay(1);
continue;
}
const size_t toRead = available < DOWNLOAD_CHUNK_SIZE ? available : DOWNLOAD_CHUNK_SIZE;
const size_t bytesRead = stream->readBytes(buffer, toRead);
if (bytesRead == 0) {
break;
}
const size_t written = file.write(buffer, bytesRead);
if (written != bytesRead) {
LOG_ERR("HTTP", "Write failed: wrote %zu of %zu bytes", written, bytesRead);
file.close();
Storage.remove(destPath.c_str());
http.end();
return FILE_ERROR;
}
downloaded += bytesRead;
if (progress && total > 0) {
progress(downloaded, total);
}
}
FileWriteStream fileStream(file, total, progress);
http.writeToStream(&fileStream);
file.close();
http.end();
const size_t downloaded = fileStream.downloaded();
LOG_DBG("HTTP", "Downloaded %zu bytes", downloaded);
if (!fileStream.ok()) {
LOG_ERR("HTTP", "Write failed during download");
Storage.remove(destPath.c_str());
return FILE_ERROR;
}
if (contentLength == 0 && downloaded == 0) {
LOG_ERR("HTTP", "Download failed: no data received");
Storage.remove(destPath.c_str());
return HTTP_ERROR;
}
// Verify download size if known
if (contentLength > 0 && downloaded != contentLength) {
LOG_ERR("HTTP", "Size mismatch: got %zu, expected %zu", downloaded, contentLength);

View File

@@ -20,24 +20,20 @@ class HttpDownloader {
};
/**
* Fetch text content from a URL.
* @param url The URL to fetch
* @param outContent The fetched content (output)
* @return true if fetch succeeded, false on error
* Fetch text content from a URL with optional credentials.
*/
static bool fetchUrl(const std::string& url, std::string& outContent);
static bool fetchUrl(const std::string& url, std::string& outContent, const std::string& username = "",
const std::string& password = "");
static bool fetchUrl(const std::string& url, Stream& stream);
static bool fetchUrl(const std::string& url, Stream& stream, const std::string& username = "",
const std::string& password = "");
/**
* Download a file to the SD card.
* @param url The URL to download
* @param destPath The destination path on SD card
* @param progress Optional progress callback
* @return DownloadError indicating success or failure type
* Download a file to the SD card with optional credentials.
*/
static DownloadError downloadToFile(const std::string& url, const std::string& destPath,
ProgressCallback progress = nullptr);
ProgressCallback progress = nullptr, const std::string& username = "",
const std::string& password = "");
private:
static constexpr size_t DOWNLOAD_CHUNK_SIZE = 1024;

View File

@@ -180,6 +180,48 @@
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.opds-server {
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 12px;
margin: 10px 0;
}
.opds-server .setting-row:last-child {
border-bottom: none;
}
.opds-actions {
display: flex;
gap: 8px;
margin-top: 8px;
}
.btn-small {
padding: 6px 14px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9em;
}
.btn-add {
background-color: var(--accent-color);
color: white;
}
.btn-add:hover {
background-color: var(--accent-hover-color);
}
.btn-delete {
background-color: #e74c3c;
color: white;
}
.btn-delete:hover {
background-color: #c0392b;
}
.btn-save-server {
background-color: #27ae60;
color: white;
}
.btn-save-server:hover {
background-color: #219a52;
}
@media (max-width: 600px) {
body {
padding: 10px;
@@ -231,6 +273,8 @@
<button class="save-btn" id="saveBtn" onclick="saveSettings()">Save Settings</button>
</div>
<div id="opds-container"></div>
<div class="card">
<p style="text-align: center; color: #95a5a6; margin: 0;">
CrossPoint E-Reader • Open Source
@@ -409,6 +453,116 @@
}
loadSettings();
// --- OPDS Server Management ---
let opdsServers = [];
function renderOpdsServer(srv, idx) {
const isNew = idx === -1;
const id = isNew ? 'new' : idx;
return '<div class="opds-server" id="opds-' + id + '">' +
'<div class="setting-row">' +
'<span class="setting-name">Server Name</span>' +
'<span class="setting-control"><input type="text" id="opds-name-' + id + '" value="' + escapeHtml(srv.name || '') + '"></span>' +
'</div>' +
'<div class="setting-row">' +
'<span class="setting-name">URL</span>' +
'<span class="setting-control"><input type="text" id="opds-url-' + id + '" value="' + escapeHtml(srv.url || '') + '"></span>' +
'</div>' +
'<div class="setting-row">' +
'<span class="setting-name">Username</span>' +
'<span class="setting-control"><input type="text" id="opds-user-' + id + '" value="' + escapeHtml(srv.username || '') + '"></span>' +
'</div>' +
'<div class="setting-row">' +
'<span class="setting-name">Password</span>' +
'<span class="setting-control"><input type="password" id="opds-pass-' + id + '" placeholder="' + (srv.hasPassword ? '(unchanged)' : '') + '"></span>' +
'</div>' +
'<div class="opds-actions">' +
'<button class="btn-small btn-save-server" onclick="saveOpdsServer(' + idx + ')">Save</button>' +
(isNew ? '' : '<button class="btn-small btn-delete" onclick="deleteOpdsServer(' + idx + ')">Delete</button>') +
'</div>' +
'</div>';
}
function renderOpdsSection() {
const container = document.getElementById('opds-container');
let html = '<div class="card"><h2>OPDS Servers</h2>';
if (opdsServers.length === 0) {
html += '<p style="color:var(--label-color);text-align:center;">No OPDS servers configured</p>';
} else {
opdsServers.forEach(function(srv, idx) {
html += renderOpdsServer(srv, idx);
});
}
html += '<div style="margin-top:12px;text-align:center;">' +
'<button class="btn-small btn-add" onclick="addOpdsServer()">+ Add Server</button>' +
'</div></div>';
container.innerHTML = html;
}
async function loadOpdsServers() {
try {
const resp = await fetch('/api/opds');
if (!resp.ok) throw new Error('Failed to load');
opdsServers = await resp.json();
renderOpdsSection();
} catch (e) {
console.error('OPDS load error:', e);
}
}
function addOpdsServer() {
const container = document.getElementById('opds-container');
const card = container.querySelector('.card');
const addBtn = card.querySelector('.btn-add').parentElement;
if (document.getElementById('opds-new')) return;
addBtn.insertAdjacentHTML('beforebegin', renderOpdsServer({name:'',url:'',username:'',hasPassword:false}, -1));
}
async function saveOpdsServer(idx) {
const id = idx === -1 ? 'new' : idx;
const data = {
name: document.getElementById('opds-name-' + id).value,
url: document.getElementById('opds-url-' + id).value,
username: document.getElementById('opds-user-' + id).value,
};
const pass = document.getElementById('opds-pass-' + id).value;
if (pass) data.password = pass;
if (idx >= 0) data.index = idx;
try {
const resp = await fetch('/api/opds', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
});
if (!resp.ok) throw new Error(await resp.text());
showMessage('OPDS server saved!', false);
await loadOpdsServers();
} catch (e) {
showMessage('Error: ' + e.message, true);
}
}
async function deleteOpdsServer(idx) {
if (!confirm('Delete this OPDS server?')) return;
try {
const resp = await fetch('/api/opds/delete', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({index: idx})
});
if (!resp.ok) throw new Error(await resp.text());
showMessage('OPDS server deleted', false);
await loadOpdsServers();
} catch (e) {
showMessage('Error: ' + e.message, true);
}
}
loadOpdsServers();
</script>
</body>
</html>