Client OPDS per Calibre Web (Sailfish OS): core C++ testato, UI Silica, packaging RPM

This commit is contained in:
2026-08-12 01:45:31 +02:00
commit 85b4f8e9b9
25 changed files with 1518 additions and 0 deletions
+135
View File
@@ -0,0 +1,135 @@
#include "apiclient.h"
#include "opdsparser.h"
#include "settings.h"
#include <QCryptographicHash>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QSslError>
#include <QStandardPaths>
#include <QUrl>
ApiClient::ApiClient(Settings *settings, QObject *parent)
: QObject(parent)
, m_settings(settings)
{
m_cacheDir = QStandardPaths::writableLocation(QStandardPaths::CacheLocation)
+ QStringLiteral("/covers");
QDir().mkpath(m_cacheDir);
}
QString ApiClient::resolveUrl(const QString &relative) const
{
if (relative.isEmpty())
return QString();
if (relative.startsWith(QLatin1String("http://"))
|| relative.startsWith(QLatin1String("https://")))
return relative;
const QString base = m_settings->baseUrl();
if (relative.startsWith(QLatin1Char('/')))
return base + relative;
return base + QLatin1Char('/') + relative;
}
void ApiClient::applyAuth(QNetworkRequest &request)
{
const QString auth = m_settings->authHeader();
if (!auth.isEmpty())
request.setRawHeader("Authorization", auth.toUtf8());
}
QNetworkReply *ApiClient::startGet(const QUrl &url)
{
QNetworkRequest request(url);
request.setRawHeader("Accept",
"application/atom+xml, application/xml, text/xml;q=0.9, */*;q=0.8");
request.setRawHeader("User-Agent", "harbour-calibreweb/0.1");
applyAuth(request);
QNetworkReply *reply = m_nam.get(request);
if (m_settings->ignoreSslErrors()) {
connect(reply, &QNetworkReply::sslErrors, reply,
static_cast<void (QNetworkReply::*)(const QList<QSslError> &)>(
&QNetworkReply::ignoreSslErrors));
}
return reply;
}
void ApiClient::getFeed(const QString &url)
{
QNetworkReply *reply = startGet(QUrl(url));
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
reply->deleteLater();
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (reply->error() != QNetworkReply::NoError || status >= 400) {
QString message;
if (status == 401)
message = QStringLiteral(
"Autenticazione richiesta (401). Controlla utente e password.");
else if (status == 404)
message = QStringLiteral("Endpoint non trovato (404). Controlla l'indirizzo.");
else
message = reply->errorString();
emit feedError(message);
return;
}
const QByteArray body = reply->readAll();
QVariantList entries;
QString nextUrl;
QString feedTitle;
QString parseError;
if (OpdsParser::parse(body, &entries, &nextUrl, &feedTitle, &parseError)) {
emit feedReady(entries, resolveUrl(nextUrl), feedTitle);
} else {
emit feedError(parseError.isEmpty()
? QStringLiteral("Risposta non valida dal server")
: parseError);
}
});
}
void ApiClient::fetchImage(const QString &url, const QString &id)
{
if (url.isEmpty()) {
emit imageReady(id, QString());
return;
}
const QString resolved = resolveUrl(url);
const QUrl imageUrl(resolved);
QString fileName = QString::fromLatin1(
QCryptographicHash::hash(imageUrl.path().toUtf8(), QCryptographicHash::Md5).toHex());
const QString suffix = QFileInfo(imageUrl.path()).suffix();
if (!suffix.isEmpty() && suffix.length() <= 5)
fileName += QLatin1Char('.') + suffix;
const QString localPath = m_cacheDir + QLatin1Char('/') + fileName;
if (QFile::exists(localPath)) {
emit imageReady(id, localPath);
return;
}
QNetworkReply *reply = startGet(imageUrl);
connect(reply, &QNetworkReply::finished, this, [this, reply, id, localPath]() {
reply->deleteLater();
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (reply->error() != QNetworkReply::NoError || status >= 400) {
emit imageReady(id, QString());
return;
}
QFile file(localPath);
if (file.open(QIODevice::WriteOnly)) {
file.write(reply->readAll());
file.close();
emit imageReady(id, localPath);
} else {
emit imageReady(id, QString());
}
});
}
+40
View File
@@ -0,0 +1,40 @@
#ifndef APICLIENT_H
#define APICLIENT_H
#include <QNetworkAccessManager>
#include <QObject>
#include <QVariantList>
class QNetworkReply;
class QNetworkRequest;
class Settings;
// Cliente OPDS: scarica i feed (getFeed), li parsa e li espone come QVariantList
// di entry (title, authors, summary, coverUrl, formats, isNavigation, linkUrl).
// Gestisce anche il fetch delle copertine in una cache locale su disco.
class ApiClient : public QObject
{
Q_OBJECT
public:
explicit ApiClient(Settings *settings, QObject *parent = 0);
Q_INVOKABLE void getFeed(const QString &url);
Q_INVOKABLE void fetchImage(const QString &url, const QString &id);
signals:
void feedReady(const QVariantList &entries, const QString &nextUrl, const QString &feedTitle);
void feedError(const QString &message);
void imageReady(const QString &id, const QString &localPath);
private:
QNetworkReply *startGet(const QUrl &url);
void applyAuth(QNetworkRequest &request);
QString resolveUrl(const QString &relative) const;
Settings *m_settings;
QNetworkAccessManager m_nam;
QString m_cacheDir;
};
#endif // APICLIENT_H
+78
View File
@@ -0,0 +1,78 @@
#include "downloader.h"
#include "settings.h"
#include <QFile>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QSslError>
#include <QUrl>
Downloader::Downloader(Settings *settings, QObject *parent)
: QObject(parent)
, m_settings(settings)
{
}
void Downloader::download(const QString &url, const QString &destPath)
{
const QUrl requestUrl(url);
QNetworkRequest request(requestUrl);
request.setRawHeader("User-Agent", "harbour-calibreweb/0.1");
request.setAttribute(QNetworkRequest::FollowRedirectsAttribute, true);
const QString auth = m_settings->authHeader();
if (!auth.isEmpty())
request.setRawHeader("Authorization", auth.toUtf8());
QNetworkReply *reply = m_nam.get(request);
if (m_settings->ignoreSslErrors()) {
connect(reply, &QNetworkReply::sslErrors, reply,
static_cast<void (QNetworkReply::*)(const QList<QSslError> &)>(
&QNetworkReply::ignoreSslErrors));
}
QFile *file = new QFile(destPath);
if (!file->open(QIODevice::WriteOnly)) {
delete file;
reply->abort();
reply->deleteLater();
emit finished(destPath, false,
QStringLiteral("Impossibile creare il file di destinazione"));
return;
}
m_files.insert(reply, file);
m_paths.insert(reply, destPath);
connect(reply, &QNetworkReply::downloadProgress, this, [this, reply](qint64 received, qint64 total) {
emit progress(received, total, m_paths.value(reply));
});
connect(reply, &QNetworkReply::readyRead, this, [this, reply]() {
QFile *file = m_files.value(reply);
if (file)
file->write(reply->readAll());
});
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
QFile *file = m_files.take(reply);
const QString path = m_paths.take(reply);
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
const bool ok = reply->error() == QNetworkReply::NoError && status >= 200 && status < 300;
if (file) {
file->flush();
file->close();
delete file;
}
if (!ok && QFile::exists(path))
QFile::remove(path);
reply->deleteLater();
QString error;
if (!ok) {
if (status == 401)
error = QStringLiteral("Autenticazione richiesta (401)");
else
error = reply->errorString();
}
emit finished(path, ok, error);
});
}
+34
View File
@@ -0,0 +1,34 @@
#ifndef DOWNLOADER_H
#define DOWNLOADER_H
#include <QHash>
#include <QNetworkAccessManager>
#include <QObject>
class QFile;
class QNetworkReply;
class Settings;
// Download di file (libri) con segnali di progresso.
// Supporta più download concorrenti.
class Downloader : public QObject
{
Q_OBJECT
public:
explicit Downloader(Settings *settings, QObject *parent = 0);
Q_INVOKABLE void download(const QString &url, const QString &destPath);
signals:
void progress(qint64 received, qint64 total, const QString &path);
void finished(const QString &path, bool ok, const QString &error);
private:
Settings *m_settings;
QNetworkAccessManager m_nam;
QHash<QNetworkReply *, QFile *> m_files;
QHash<QNetworkReply *, QString> m_paths;
};
#endif // DOWNLOADER_H
+27
View File
@@ -0,0 +1,27 @@
#include <QGuiApplication>
#include <QQuickView>
#include <QQmlContext>
#include <sailfishapp.h>
#include "apiclient.h"
#include "downloader.h"
#include "settings.h"
Q_DECL_EXPORT int main(int argc, char *argv[])
{
QGuiApplication *app = SailfishApp::application(argc, argv);
QQuickView *view = SailfishApp::createView();
Settings settings;
ApiClient apiClient(&settings);
Downloader downloader(&settings);
view->rootContext()->setContextProperty(QStringLiteral("appSettings"), &settings);
view->rootContext()->setContextProperty(QStringLiteral("apiClient"), &apiClient);
view->rootContext()->setContextProperty(QStringLiteral("downloader"), &downloader);
view->setSource(SailfishApp::pathTo(QStringLiteral("qml/harbour-calibreweb.qml")));
view->show();
return app->exec();
}
+123
View File
@@ -0,0 +1,123 @@
#include "opdsparser.h"
#include <QStringList>
#include <QVariantMap>
#include <QXmlStreamReader>
static QString elementText(QXmlStreamReader &reader)
{
return reader.readElementText(QXmlStreamReader::IncludeChildElements).trimmed();
}
static QVariantMap parseEntry(QXmlStreamReader &reader)
{
QVariantMap entry;
QStringList authors;
QVariantList formats;
QString title;
QString summary;
QString id;
QString coverHref;
QString thumbHref;
QString navHref;
while (!reader.atEnd()) {
reader.readNext();
if (reader.tokenType() == QXmlStreamReader::EndElement
&& reader.name() == QLatin1String("entry"))
break;
if (reader.tokenType() != QXmlStreamReader::StartElement)
continue;
const QStringRef name = reader.name();
if (name == QLatin1String("title")) {
title = elementText(reader);
} else if (name == QLatin1String("id")) {
id = elementText(reader);
} else if (name == QLatin1String("name")) {
const QString author = elementText(reader);
if (!author.isEmpty())
authors.append(author);
} else if (name == QLatin1String("content") || name == QLatin1String("summary")) {
if (summary.isEmpty())
summary = elementText(reader);
} else if (name == QLatin1String("link")) {
const QXmlStreamAttributes attrs = reader.attributes();
const QString rel = attrs.value(QLatin1String("rel")).toString();
const QString href = attrs.value(QLatin1String("href")).toString();
const QString type = attrs.value(QLatin1String("type")).toString();
if (rel == QLatin1String("http://opds-spec.org/image")) {
coverHref = href;
} else if (rel == QLatin1String("http://opds-spec.org/image/thumbnail")) {
thumbHref = href;
} else if (rel == QLatin1String("http://opds-spec.org/acquisition")) {
QVariantMap format;
format.insert(QStringLiteral("format"), attrs.value(QLatin1String("title")).toString());
format.insert(QStringLiteral("mime"), type);
format.insert(QStringLiteral("size"), attrs.value(QLatin1String("length")).toString().toLongLong());
format.insert(QStringLiteral("url"), href);
formats.append(format);
} else if (rel == QLatin1String("subsection")) {
if (navHref.isEmpty())
navHref = href;
} else if (navHref.isEmpty() && type.contains(QLatin1String("opds-catalog"))) {
// calibre-web: i link di navigazione dell'indice non hanno rel="subsection"
navHref = href;
}
}
}
entry.insert(QStringLiteral("title"), title);
entry.insert(QStringLiteral("authors"), authors.join(QStringLiteral(", ")));
entry.insert(QStringLiteral("summary"), summary);
entry.insert(QStringLiteral("coverUrl"), !coverHref.isEmpty() ? coverHref : thumbHref);
entry.insert(QStringLiteral("formats"), formats);
entry.insert(QStringLiteral("id"), id);
entry.insert(QStringLiteral("isNavigation"), !navHref.isEmpty());
entry.insert(QStringLiteral("linkUrl"), navHref);
return entry;
}
bool OpdsParser::parse(const QByteArray &xml,
QVariantList *entries,
QString *nextUrl,
QString *feedTitle,
QString *errorMessage)
{
QXmlStreamReader reader(xml);
QVariantList result;
QString next;
QString title;
while (!reader.atEnd()) {
reader.readNext();
if (reader.tokenType() != QXmlStreamReader::StartElement)
continue;
const QStringRef name = reader.name();
if (name == QLatin1String("entry")) {
result.append(parseEntry(reader));
} else if (name == QLatin1String("link")) {
const QXmlStreamAttributes attrs = reader.attributes();
if (attrs.value(QLatin1String("rel")).toString() == QLatin1String("next"))
next = attrs.value(QLatin1String("href")).toString();
} else if (name == QLatin1String("title") && title.isEmpty()) {
title = elementText(reader);
}
}
if (reader.hasError() && result.isEmpty()) {
if (errorMessage)
*errorMessage = reader.errorString();
return false;
}
if (entries)
*entries = result;
if (nextUrl)
*nextUrl = next;
if (feedTitle)
*feedTitle = title;
return true;
}
+22
View File
@@ -0,0 +1,22 @@
#ifndef OPDSPARSER_H
#define OPDSPARSER_H
#include <QByteArray>
#include <QVariantList>
#include <QString>
// Parser minimalista dei feed Atom/OPDS.
// Ogni <entry> diventa un QVariantMap con:
// title, authors, summary, coverUrl, formats (lista di {format, mime, size, url}),
// id, isNavigation, linkUrl
class OpdsParser
{
public:
static bool parse(const QByteArray &xml,
QVariantList *entries,
QString *nextUrl,
QString *feedTitle,
QString *errorMessage);
};
#endif // OPDSPARSER_H
+88
View File
@@ -0,0 +1,88 @@
#include "settings.h"
#include <QDir>
#include <QStandardPaths>
Settings::Settings(QObject *parent)
: QObject(parent)
, m_settings(QStringLiteral("harbour"), QStringLiteral("calibreweb"))
{
}
QString Settings::serverUrl() const
{
return m_settings.value(QStringLiteral("serverUrl")).toString();
}
void Settings::setServerUrl(const QString &value)
{
if (value == serverUrl())
return;
m_settings.setValue(QStringLiteral("serverUrl"), value);
emit serverUrlChanged();
}
QString Settings::username() const
{
return m_settings.value(QStringLiteral("username")).toString();
}
void Settings::setUsername(const QString &value)
{
if (value == username())
return;
m_settings.setValue(QStringLiteral("username"), value);
emit usernameChanged();
}
QString Settings::password() const
{
return m_settings.value(QStringLiteral("password")).toString();
}
void Settings::setPassword(const QString &value)
{
if (value == password())
return;
m_settings.setValue(QStringLiteral("password"), value);
emit passwordChanged();
}
bool Settings::ignoreSslErrors() const
{
return m_settings.value(QStringLiteral("ignoreSslErrors"), false).toBool();
}
void Settings::setIgnoreSslErrors(bool value)
{
if (value == ignoreSslErrors())
return;
m_settings.setValue(QStringLiteral("ignoreSslErrors"), value);
emit ignoreSslErrorsChanged();
}
QString Settings::downloadDir() const
{
QString dir = QStandardPaths::writableLocation(QStandardPaths::DownloadLocation);
if (dir.isEmpty())
dir = QStringLiteral("/home/nemo/Downloads");
QDir().mkpath(dir);
return dir;
}
QString Settings::authHeader() const
{
const QString user = username();
if (user.isEmpty())
return QString();
const QString credentials = user + QLatin1Char(':') + password();
return QStringLiteral("Basic ") + QString::fromLatin1(credentials.toUtf8().toBase64());
}
QString Settings::baseUrl() const
{
QString url = serverUrl().trimmed();
while (url.endsWith(QLatin1Char('/')))
url.chop(1);
return url;
}
+50
View File
@@ -0,0 +1,50 @@
#ifndef SETTINGS_H
#define SETTINGS_H
#include <QObject>
#include <QSettings>
class Settings : public QObject
{
Q_OBJECT
Q_PROPERTY(QString serverUrl READ serverUrl WRITE setServerUrl NOTIFY serverUrlChanged)
Q_PROPERTY(QString username READ username WRITE setUsername NOTIFY usernameChanged)
Q_PROPERTY(QString password READ password WRITE setPassword NOTIFY passwordChanged)
Q_PROPERTY(bool ignoreSslErrors READ ignoreSslErrors WRITE setIgnoreSslErrors NOTIFY ignoreSslErrorsChanged)
Q_PROPERTY(QString downloadDir READ downloadDir CONSTANT)
public:
explicit Settings(QObject *parent = 0);
QString serverUrl() const;
void setServerUrl(const QString &value);
QString username() const;
void setUsername(const QString &value);
QString password() const;
void setPassword(const QString &value);
bool ignoreSslErrors() const;
void setIgnoreSslErrors(bool value);
QString downloadDir() const;
// Header "Authorization: Basic ..." pronto da usare, vuoto se utente non impostato
QString authHeader() const;
// URL del server senza slash finale
QString baseUrl() const;
signals:
void serverUrlChanged();
void usernameChanged();
void passwordChanged();
void ignoreSslErrorsChanged();
private:
QSettings m_settings;
};
#endif // SETTINGS_H