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
+6
View File
@@ -0,0 +1,6 @@
build/
build-*/
*.rpm
*.user
Makefile
.qmake.stash
+99
View File
@@ -0,0 +1,99 @@
# harbour-calibreweb
Client **OPDS** per Sailfish OS: naviga e scarica libri dalla tua istanza
**Calibre Web** (o dal Content Server di Calibre).
Funziona con qualsiasi server OPDS, ma è pensato per Calibre Web:
feed di navigazione, ricerca, copertine, download con progresso.
## Perché OPDS e non l'API REST
Le versioni recenti di calibre-web (upstream) **non hanno più l'API REST**:
`/api/*` è stata rimossa e non esiste in nessuna release 0.6.9+ né in master.
L'interfaccia stabile e documentata è **OPDS** (`/opds`, `/opds/new`,
`/opds/search?query=...`), che usa HTTP Basic Auth ed espone i link di
download per formato. OPDS è supportato anche dal Content Server di Calibre,
quindi questa app copre entrambi i backend.
## Funzionalità
- Sezioni del catalogo (novità, autori, categorie, scaffali, ...) dall'indice `/opds`
- Paginazione automatica (link `rel="next"`)
- Ricerca testuale (`/opds/search`)
- Copertine con cache locale
- Dettaglio libro: autore, descrizione, formati con dimensione
- Download con barra di progresso in `~/Downloads`
- Autenticazione HTTP Basic (utente vuoto = browse anonimo)
- Opzione "ignora errori SSL" per certificati autofirmati
## Build
Serve il [Sailfish SDK](https://sailfishos.org/wiki/Sailfish_SDK)
(su Linux: `flatpak install flathub org.sailfishos.sdk` o tarball).
```sh
git clone <questo repo> harbour-calibreweb
cd harbour-calibreweb
# scegli un target, es.:
sfdk target install SailfishOS-4.6.0.11-armv7hl
sfdk target use SailfishOS-4.6.0.11-armv7hl
sfdk build # produce l'RPM in RPMS/
sfdk deploy # installa sul dispositivo connesso
```
In alternativa, installa l'RPM a mano:
```sh
pkcon install-local harbour-calibreweb-0.1.0-1.armv7hl.rpm
```
Target consigliati in base al dispositivo: `armv7hl` (Xperia X, Jolla C…),
`aarch64` (Xperia 10 II/III, Jolla C2), `x86_64` (emulatore).
## Configurazione
Apri l'app → menù → **Impostazioni**:
| Campo | Valore |
|---|---|
| Indirizzo Calibre Web | es. `https://calibre.example.com` (o con sottopath) |
| Utente | vuoto se il browse anonimo è attivo su calibre-web |
| Password | quella dell'account (Basic Auth) |
| Ignora SSL | attivalo solo se il server ha un certificato autofirmato |
Poi **Prova connessione** e naviga.
## Struttura
```
src/ C++: settings (QSettings), apiclient (feed+copertine),
downloader (progressi), opdsparser (Atom/OPDS), main
qml/pages/ MainPage (indice), FeedPage (liste+paginazione),
BookDetailPage (dettaglio+download), SearchPage, SettingsPage
qml/cover/ cover dell'app
icons/ icone 86/108 px + script di generazione
rpm/ spec e desktop file
```
## Limiti noti
- La password è salvata in chiaro nelle impostazioni dell'app
(`~/.config/harbour/calibreweb.conf`) — accettabile per uso personale.
- La ricerca usa il motore del server; risultati identici alla UI web.
- Il core C++ (parsing, auth, download) è testato con Qt 5.15 contro feed
OPDS reali e un mock con Basic Auth; la UI Silica richiede il SDK per
la verifica su dispositivo.
## Test del core
```sh
# mock server OPDS + test end-to-end (vedi repository /tmp in locale):
python3 /tmp/mock_opds.py
# compila e lancia /tmp/opds_test (Qt 5.15+)
```
## Licenza
MIT
+28
View File
@@ -0,0 +1,28 @@
TEMPLATE = app
TARGET = harbour-calibreweb
CONFIG += sailfishapp
CONFIG += c++11
SOURCES += \
src/main.cpp \
src/settings.cpp \
src/apiclient.cpp \
src/downloader.cpp \
src/opdsparser.cpp
HEADERS += \
src/settings.h \
src/apiclient.h \
src/downloader.h \
src/opdsparser.h
OTHER_FILES += \
qml/harbour-calibreweb.qml \
qml/pages/*.qml \
qml/cover/*.qml \
rpm/harbour-calibreweb.spec \
rpm/harbour-calibreweb.desktop \
harbour-calibreweb.yaml
DISTFILES += $$OTHER_FILES
+15
View File
@@ -0,0 +1,15 @@
Name: harbour-calibreweb
Summary: Client OPDS per Calibre Web
Version: 0.1.0
Release: 1
Group: Qt/Qt
License: MIT
URL: https://github.com/kaneda/harbour-calibreweb
Description: |
Client per navigare e scaricare libri da un'istanza Calibre Web
(o dal Content Server di Calibre) tramite il protocollo OPDS.
Supporta autenticazione di base, ricerca e download in tutti i
formati disponibili (EPUB, MOBI, PDF, ...).
Sources:
- '%{name}-%{version}.tar.gz'
Builder: qmake5
Binary file not shown.

After

Width:  |  Height:  |  Size: 796 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 629 B

+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env python3
"""Genera le icone dell'app: harbour-calibreweb.png (86x86) e -108 (108x108)."""
from PIL import Image, ImageDraw
BG = (46, 52, 64) # #2E3440
ACCENT = (136, 192, 208) # #88C0D0
PAGE = (229, 233, 240) # #E5E9F0
SPINE = (94, 129, 172) # #5E81AC
def draw_icon(size):
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
margin = size * 0.045
radius = size * 0.16
# sfondo arrotondato
d.rounded_rectangle([margin, margin, size - margin, size - margin],
radius=radius, fill=BG)
# libro aperto: due pagine trapezoidali + dorso
cx = size / 2
top = size * 0.34
bot = size * 0.68
half = size * 0.16
outer_l = size * 0.22
outer_r = size * 0.78
left_page = [(cx - half, top), (cx, top), (cx, bot), (outer_l, bot + size * 0.04)]
right_page = [(cx, top), (cx + half, top), (outer_r, bot + size * 0.04), (cx, bot)]
d.polygon(left_page, fill=ACCENT)
d.polygon(right_page, fill=ACCENT)
# dorso
d.rectangle([cx - size * 0.014, top, cx + size * 0.014, bot], fill=SPINE)
# righe delle pagine
lw = max(1, int(size * 0.012))
for y_frac in (0.45, 0.52, 0.59):
y = size * y_frac
d.line([(cx - half + size * 0.03, y), (outer_l + size * 0.06, y + size * 0.025)],
fill=PAGE, width=lw)
d.line([(cx + half - size * 0.03, y), (outer_r - size * 0.06, y + size * 0.025)],
fill=PAGE, width=lw)
return img
def main():
draw_icon(108).save("icons/harbour-calibreweb-108.png")
draw_icon(86).save("icons/harbour-calibreweb.png")
print("icone scritte")
if __name__ == "__main__":
main()
+23
View File
@@ -0,0 +1,23 @@
import QtQuick 2.0
import Sailfish.Silica 1.0
CoverBackground {
Column {
anchors.centerIn: parent
spacing: Theme.paddingMedium
Image {
anchors.horizontalCenter: parent.horizontalCenter
source: "/usr/share/harbour-calibreweb/icons/harbour-calibreweb.png"
sourceSize.width: 86
sourceSize.height: 86
}
Label {
anchors.horizontalCenter: parent.horizontalCenter
text: "Calibre Web"
color: Theme.primaryColor
font.pixelSize: Theme.fontSizeSmall
}
}
}
+14
View File
@@ -0,0 +1,14 @@
import QtQuick 2.0
import Sailfish.Silica 1.0
import "pages"
import "cover"
ApplicationWindow {
id: app
initialPage: Component { MainPage {} }
cover: Component { CoverPage {} }
allowedOrientations: Orientation.Portrait
_defaultPageOrientations: Orientation.Portrait
}
+176
View File
@@ -0,0 +1,176 @@
import QtQuick 2.0
import Sailfish.Silica 1.0
Page {
id: page
property var bookData: ({})
property string coverSource: ""
property bool downloading: false
property string downloadError: ""
SilicaFlickable {
anchors.fill: parent
contentHeight: column.height + Theme.paddingLarge
Column {
id: column
width: parent.width
PageHeader { title: bookData.title }
Image {
id: cover
anchors.horizontalCenter: parent.horizontalCenter
width: Math.min(parent.width * 0.45, 260)
height: width * 1.5
fillMode: Image.PreserveAspectFit
source: page.coverSource
visible: source.length > 0
}
Label {
x: Theme.horizontalPageMargin
width: parent.width - 2 * Theme.horizontalPageMargin
text: bookData.authors
color: Theme.secondaryColor
wrapMode: Text.Wrap
visible: text.length > 0
}
Label {
x: Theme.horizontalPageMargin
width: parent.width - 2 * Theme.horizontalPageMargin
text: bookData.summary
wrapMode: Text.Wrap
font.pixelSize: Theme.fontSizeSmall
visible: text.length > 0
}
SectionHeader { text: "Formati" }
Repeater {
model: bookData.formats
delegate: ListItem {
id: formatItem
contentHeight: Theme.itemSizeMedium
enabled: !page.downloading
Label {
anchors.left: parent.left
anchors.leftMargin: Theme.horizontalPageMargin
anchors.verticalCenter: parent.verticalCenter
text: modelData.format
+ (modelData.size > 0 ? " (" + formatSize(modelData.size) + ")" : "")
color: formatItem.highlighted ? Theme.highlightColor : Theme.primaryColor
}
onClicked: startDownload(modelData)
}
}
Label {
x: Theme.horizontalPageMargin
width: parent.width - 2 * Theme.horizontalPageMargin
text: page.downloadError
color: Theme.errorColor
wrapMode: Text.Wrap
visible: text.length > 0
}
Item { width: 1; height: Theme.paddingLarge }
}
}
Column {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
visible: page.downloading
height: Theme.itemSizeMedium
ProgressBar {
id: progressBar
anchors.left: parent.left
anchors.right: parent.right
value: 0
minimumValue: 0
maximumValue: 1
}
Label {
anchors.horizontalCenter: parent.horizontalCenter
text: "Download in corso…"
color: Theme.secondaryColor
font.pixelSize: Theme.fontSizeSmall
}
}
Toast {
id: toast
}
function formatSize(bytes) {
if (bytes <= 0)
return ""
if (bytes < 1048576)
return Math.round(bytes / 1024) + " KB"
return (bytes / 1048576).toFixed(1) + " MB"
}
function resolveUrl(u) {
if (u.indexOf("http://") === 0 || u.indexOf("https://") === 0)
return u
var base = appSettings.baseUrl
return base + (u.charAt(0) === "/" ? u : "/" + u)
}
function sanitizeFileName(s) {
return s.replace(/[\\/:*?"<>|]/g, "_").replace(/\s+/g, " ").trim()
}
function startDownload(format) {
if (page.downloading)
return
var ext = format.format.toLowerCase()
var dest = appSettings.downloadDir + "/"
+ sanitizeFileName(bookData.title) + "." + ext
page.downloading = true
page.downloadError = ""
progressBar.value = 0
progressBar.maximumValue = format.size > 0 ? format.size : 1
downloader.download(resolveUrl(format.url), dest)
}
Component.onCompleted: {
if (bookData.coverUrl && bookData.coverUrl.length > 0)
apiClient.fetchImage(bookData.coverUrl, "detail:" + bookData.id)
}
Connections {
target: apiClient
onImageReady: {
if (id === "detail:" + bookData.id)
page.coverSource = localPath
}
}
Connections {
target: downloader
onProgress: {
if (!page.downloading)
return
progressBar.value = received
if (total > 0)
progressBar.maximumValue = total
}
onFinished: {
page.downloading = false
if (ok) {
page.downloadError = ""
toast.show("Salvato in " + path)
} else {
page.downloadError = "Errore: " + error
}
}
}
}
+187
View File
@@ -0,0 +1,187 @@
import QtQuick 2.0
import Sailfish.Silica 1.0
Page {
id: page
property string feedUrl
property string pageTitle
property string nextUrl: ""
property bool loading: false
property bool loadingMore: false
property string errorMessage: ""
SilicaListView {
id: listView
anchors.fill: parent
header: PageHeader { title: page.pageTitle }
model: feedModel
clip: true
delegate: ListItem {
id: item
contentHeight: Theme.itemSizeMedium
Image {
id: coverThumb
anchors.left: parent.left
anchors.leftMargin: Theme.horizontalPageMargin
anchors.verticalCenter: parent.verticalCenter
width: Theme.itemSizeMedium * 0.68
height: Theme.itemSizeMedium * 0.92
fillMode: Image.PreserveAspectFit
visible: source.length > 0 && !model.isNav
source: model.coverSource
}
Column {
anchors.left: model.isNav ? parent.left : coverThumb.right
anchors.leftMargin: Theme.horizontalPageMargin
anchors.right: parent.right
anchors.rightMargin: Theme.horizontalPageMargin
anchors.verticalCenter: parent.verticalCenter
Label {
width: parent.width
text: model.entryTitle
color: item.highlighted ? Theme.highlightColor : Theme.primaryColor
truncationMode: TruncationMode.Elide
}
Label {
width: parent.width
text: model.entryAuthors
color: Theme.secondaryColor
font.pixelSize: Theme.fontSizeSmall
truncationMode: TruncationMode.Elide
visible: text.length > 0 && !model.isNav
}
}
Icon {
anchors.right: parent.right
anchors.rightMargin: Theme.horizontalPageMargin
anchors.verticalCenter: parent.verticalCenter
source: "image://theme/icon-m-right"
visible: model.isNav
}
onClicked: {
if (model.isNav) {
pageStack.push(Qt.resolvedUrl("FeedPage.qml"),
{ feedUrl: model.entryLink, pageTitle: model.entryTitle })
} else {
pageStack.push(Qt.resolvedUrl("BookDetailPage.qml"),
{ bookData: model.entryData })
}
}
}
footer: Item {
width: parent.width
height: page.nextUrl.length > 0 ? Theme.itemSizeSmall : 0
BusyIndicator {
anchors.centerIn: parent
running: page.loadingMore
size: BusyIndicatorSize.Small
}
}
PullDownMenu {
MenuItem {
text: "Aggiorna"
onClicked: reload()
}
MenuItem {
text: "Impostazioni"
onClicked: pageStack.push(Qt.resolvedUrl("SettingsPage.qml"))
}
}
ViewPlaceholder {
enabled: feedModel.count === 0 && !page.loading
text: page.errorMessage.length > 0 ? page.errorMessage : "Nessun risultato"
}
BusyIndicator {
anchors.centerIn: parent
running: page.loading
size: BusyIndicatorSize.Large
}
onContentYChanged: tryLoadMore()
onCountChanged: tryLoadMore()
}
ListModel {
id: feedModel
}
function reload() {
feedModel.clear()
page.nextUrl = ""
loadFeed(page.feedUrl)
}
function loadFeed(url) {
page.loading = true
page.errorMessage = ""
apiClient.getFeed(url)
}
function loadMore() {
page.loadingMore = true
apiClient.getFeed(page.nextUrl)
}
function tryLoadMore() {
if (page.nextUrl.length === 0 || page.loadingMore || page.loading)
return
if (listView.contentY + listView.height >= listView.contentHeight - Theme.itemSizeMedium * 2)
loadMore()
}
onNextUrlChanged: tryLoadMore()
Component.onCompleted: loadFeed(feedUrl)
Connections {
target: apiClient
onFeedReady: {
if (pageStack.currentPage !== page)
return
page.loading = false
page.loadingMore = false
for (var i = 0; i < entries.length; i++) {
var e = entries[i]
var isNav = e.isNavigation
feedModel.append({
entryTitle: e.title,
entryAuthors: e.authors,
entryLink: e.linkUrl,
isNav: isNav,
entryData: e,
coverSource: "",
coverId: e.id
})
if (!isNav && e.coverUrl.length > 0)
apiClient.fetchImage(e.coverUrl, e.id)
}
page.nextUrl = nextUrl
if (feedModel.count === 0 && page.errorMessage.length === 0)
page.errorMessage = "Nessun risultato"
}
onFeedError: {
page.loading = false
page.loadingMore = false
page.errorMessage = message
}
onImageReady: {
for (var i = 0; i < feedModel.count; i++) {
if (feedModel.get(i).coverId === id) {
feedModel.setProperty(i, "coverSource", localPath)
break
}
}
}
}
}
+107
View File
@@ -0,0 +1,107 @@
import QtQuick 2.0
import Sailfish.Silica 1.0
Page {
id: page
property bool loading: false
property string errorMessage: ""
SilicaListView {
id: listView
anchors.fill: parent
header: PageHeader { title: "Calibre Web" }
model: sectionModel
delegate: ListItem {
id: sectionItem
contentHeight: Theme.itemSizeMedium
Label {
anchors.left: parent.left
anchors.leftMargin: Theme.horizontalPageMargin
anchors.verticalCenter: parent.verticalCenter
text: model.title
color: sectionItem.highlighted ? Theme.highlightColor : Theme.primaryColor
}
Icon {
anchors.right: parent.right
anchors.rightMargin: Theme.horizontalPageMargin
anchors.verticalCenter: parent.verticalCenter
source: "image://theme/icon-m-right"
}
onClicked: {
pageStack.push(Qt.resolvedUrl("FeedPage.qml"),
{ feedUrl: model.linkUrl, pageTitle: model.title })
}
}
PullDownMenu {
MenuItem {
text: "Cerca"
onClicked: pageStack.push(Qt.resolvedUrl("SearchPage.qml"))
}
MenuItem {
text: "Impostazioni"
onClicked: pageStack.push(Qt.resolvedUrl("SettingsPage.qml"))
}
MenuItem {
text: "Aggiorna"
onClicked: loadIndex()
}
}
ViewPlaceholder {
enabled: sectionModel.count === 0 && !page.loading
text: page.errorMessage.length > 0
? page.errorMessage
: "Apri le impostazioni e inserisci l'indirizzo del tuo Calibre Web"
}
BusyIndicator {
anchors.centerIn: parent
running: page.loading
size: BusyIndicatorSize.Large
}
}
ListModel {
id: sectionModel
}
function loadIndex() {
var base = appSettings.baseUrl
if (base.length === 0) {
page.errorMessage = "Server non configurato"
return
}
page.loading = true
page.errorMessage = ""
apiClient.getFeed(base + "/opds")
}
Component.onCompleted: loadIndex()
Connections {
target: apiClient
onFeedReady: {
if (pageStack.currentPage !== page)
return
page.loading = false
sectionModel.clear()
for (var i = 0; i < entries.length; i++) {
var e = entries[i]
if (e.isNavigation)
sectionModel.append({ title: e.title, linkUrl: e.linkUrl })
}
if (sectionModel.count === 0)
page.errorMessage = "Nessuna sezione trovata nel feed OPDS"
}
onFeedError: {
page.loading = false
page.errorMessage = message
}
}
}
+52
View File
@@ -0,0 +1,52 @@
import QtQuick 2.0
import Sailfish.Silica 1.0
Page {
id: page
SilicaFlickable {
anchors.fill: parent
contentHeight: column.height
Column {
id: column
width: parent.width
PageHeader { title: "Cerca" }
SearchField {
id: searchField
width: parent.width
placeholderText: "Titolo o autore…"
EnterKey.enabled: text.length > 0
EnterKey.onClicked: search()
}
Button {
anchors.horizontalCenter: parent.horizontalCenter
text: "Cerca"
enabled: searchField.text.length > 0
onClicked: search()
}
Label {
x: Theme.horizontalPageMargin
width: parent.width - 2 * Theme.horizontalPageMargin
text: "La ricerca usa il motore del server (OPDS search)."
color: Theme.secondaryColor
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.Wrap
}
}
}
function search() {
var base = appSettings.baseUrl
var query = searchField.text.trim()
if (base.length === 0 || query.length === 0)
return
var url = base + "/opds/search?query=" + encodeURIComponent(query)
pageStack.push(Qt.resolvedUrl("FeedPage.qml"),
{ feedUrl: url, pageTitle: "Risultati: " + query })
}
}
+103
View File
@@ -0,0 +1,103 @@
import QtQuick 2.0
import Sailfish.Silica 1.0
Page {
id: page
property string testResult: ""
property bool testOk: false
SilicaFlickable {
anchors.fill: parent
contentHeight: column.height
Column {
id: column
width: parent.width
PageHeader { title: "Impostazioni" }
SectionHeader { text: "Server" }
TextField {
id: serverField
width: parent.width
label: "Indirizzo Calibre Web"
placeholderText: "https://calibre.example.com"
text: appSettings.serverUrl
inputMethodHints: Qt.ImhUrlCharactersOnly
onTextChanged: appSettings.serverUrl = text
}
TextField {
id: userField
width: parent.width
label: "Utente (vuoto = accesso anonimo)"
text: appSettings.username
onTextChanged: appSettings.username = text
}
TextField {
id: passField
width: parent.width
label: "Password"
text: appSettings.password
echoMode: TextInput.Password
onTextChanged: appSettings.password = text
}
Switch {
text: "Ignora errori certificato SSL"
description: "Da attivare se il server usa un certificato autofirmato"
checked: appSettings.ignoreSslErrors
onCheckedChanged: appSettings.ignoreSslErrors = checked
}
SectionHeader { text: "Connessione" }
Button {
anchors.horizontalCenter: parent.horizontalCenter
text: "Prova connessione"
onClicked: testConnection()
}
Label {
x: Theme.horizontalPageMargin
width: parent.width - 2 * Theme.horizontalPageMargin
text: page.testResult
color: page.testOk ? Theme.secondaryHighlightColor : Theme.errorColor
wrapMode: Text.Wrap
visible: text.length > 0
}
Item { width: 1; height: Theme.paddingLarge }
}
}
function testConnection() {
var base = appSettings.baseUrl
if (base.length === 0) {
page.testResult = "Inserisci l'indirizzo del server"
page.testOk = false
return
}
page.testResult = "Verifica in corso…"
apiClient.getFeed(base + "/opds")
}
Connections {
target: apiClient
onFeedReady: {
if (pageStack.currentPage !== page)
return
page.testOk = true
page.testResult = "Connessione OK: " + entries.length + " sezioni trovate"
}
onFeedError: {
if (pageStack.currentPage !== page)
return
page.testOk = false
page.testResult = message
}
}
}
+9
View File
@@ -0,0 +1,9 @@
[Desktop Entry]
Name=Calibre Web
Name[it_IT]=Calibre Web
Comment=Client OPDS per Calibre Web
Comment[it_IT]=Client OPDS per Calibre Web
Exec=harbour-calibreweb
Icon=harbour-calibreweb
Type=Application
Categories=Office;Viewer;
+47
View File
@@ -0,0 +1,47 @@
Name: harbour-calibreweb
Summary: Client OPDS per Calibre Web
Version: 0.1.0
Release: 1
Group: Qt/Qt
License: MIT
URL: https://github.com/kaneda/harbour-calibreweb
Source0: %{name}-%{version}.tar.gz
Source1: %{name}.desktop
Requires: sailfishsilica-qt5 >= 0.10.9
BuildRequires: pkgconfig(sailfishapp) >= 1.0.2
BuildRequires: pkgconfig(Qt5Core)
BuildRequires: pkgconfig(Qt5Qml)
BuildRequires: pkgconfig(Qt5Quick)
BuildRequires: desktop-file-utils
%description
Client per navigare e scaricare libri da un'istanza Calibre Web
(o dal Content Server di Calibre) tramite il protocollo OPDS.
Supporta autenticazione di base, ricerca, copertine e download
in tutti i formati disponibili sulla libreria.
%prep
%setup -q -n %{name}-%{version}
%build
%qmake5
make %{?_smp_mflags}
%install
make install INSTALL_ROOT=%{buildroot}
mkdir -p %{buildroot}%{_datadir}/applications
install -m 0644 %{SOURCE1} %{buildroot}%{_datadir}/applications/%{name}.desktop
mkdir -p %{buildroot}%{_datadir}/icons/hicolor/86x86/apps
mkdir -p %{buildroot}%{_datadir}/icons/hicolor/108x108/apps
install -m 0644 icons/%{name}.png %{buildroot}%{_datadir}/icons/hicolor/86x86/apps/%{name}.png
install -m 0644 icons/%{name}-108.png %{buildroot}%{_datadir}/icons/hicolor/108x108/apps/%{name}-108.png
desktop-file-validate %{buildroot}%{_datadir}/applications/%{name}.desktop || true
%files
%defattr(-,root,root,-)
%{_bindir}/%{name}
%{_datadir}/applications/%{name}.desktop
%{_datadir}/icons/hicolor/86x86/apps/%{name}.png
%{_datadir}/icons/hicolor/108x108/apps/%{name}.png
%{_datadir}/%{name}/qml
+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