From 646033e16f79352d9a393465fc6cb113161f951c Mon Sep 17 00:00:00 2001 From: Matteo Benedetto Date: Sat, 5 Sep 2026 18:03:44 +0200 Subject: [PATCH] feat: add KDE Plasma podcast plasmoid --- .gitignore | 10 + CMakeLists.txt | 37 +++ README.md | 82 +++++ package/contents/config/config.qml | 10 + package/contents/config/configGeneral.qml | 107 +++++++ package/contents/config/main.xml | 26 ++ package/contents/lib/qmldir | 2 + package/contents/ui/main.qml | 277 +++++++++++++++++ package/contents/ui/podcastCatalog.js | 1 + package/metadata.json | 16 + src/ilpostapiclient.cpp | 357 ++++++++++++++++++++++ src/ilpostapiclient.h | 86 ++++++ src/plugin.cpp | 19 ++ 13 files changed, 1030 insertions(+) create mode 100644 .gitignore create mode 100644 CMakeLists.txt create mode 100644 README.md create mode 100644 package/contents/config/config.qml create mode 100644 package/contents/config/configGeneral.qml create mode 100644 package/contents/config/main.xml create mode 100644 package/contents/lib/qmldir create mode 100644 package/contents/ui/main.qml create mode 100644 package/contents/ui/podcastCatalog.js create mode 100644 package/metadata.json create mode 100644 src/ilpostapiclient.cpp create mode 100644 src/ilpostapiclient.h create mode 100644 src/plugin.cpp diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..84632c2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +build/ +package/contents/lib/*.so +package/contents/lib/*.dylib +package/contents/lib/*.dll +*.o +CMakeCache.txt +CMakeFiles/ +cmake_install.cmake +compile_commands.json +.DS_Store diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..13ab33a --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,37 @@ +cmake_minimum_required(VERSION 3.16) +project(ilpostpodcasts LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_AUTOMOC ON) + +find_package(Qt6 REQUIRED COMPONENTS Core Network Qml) + +add_library(ilpostcredentialsplugin SHARED + src/plugin.cpp + src/ilpostapiclient.cpp + src/ilpostapiclient.h +) + +target_link_libraries(ilpostcredentialsplugin PRIVATE + Qt6::Core + Qt6::Network + Qt6::Qml +) + +target_include_directories(ilpostcredentialsplugin PRIVATE src) + +set_target_properties(ilpostcredentialsplugin PROPERTIES + LIBRARY_OUTPUT_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/package/contents/lib" + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/package/contents/lib" +) + +add_custom_target(package ALL DEPENDS ilpostcredentialsplugin) + +add_custom_target(install-plasmoid + COMMAND kpackagetool6 -t Plasma/Applet -u + "${CMAKE_CURRENT_SOURCE_DIR}/package" + DEPENDS ilpostcredentialsplugin + WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" + COMMENT "Install ilPost Podcasts plasmoid" +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..e39a057 --- /dev/null +++ b/README.md @@ -0,0 +1,82 @@ +# Il Post Podcasts — plasmoide KDE Plasma 6 + +Plasmoide standalone per KDE Plasma 6 che visualizza gli episodi più recenti dei podcast de Il Post, mette i programmi preferiti in cima e permette di riprodurre gli episodi con un player KDE compatibile con MPRIS. + +> **Progetto derivato, non libreria upstream.** Questo repository **non è** il repository upstream originale clonato e non pretende di essere una nuova versione della libreria `thekoma/ilpostapi`. È un progetto separato, con scopo, struttura, UI e ciclo di rilascio propri, che integra e utilizza gli endpoint/API individuati nel progetto upstream. + +## Relazione con upstream + +- **Upstream di riferimento:** [`thekoma/ilpostapi`](https://github.com/thekoma/ilpostapi) +- **Questo progetto:** [`enne2/ilpost-podcasts-plasmoid`](https://git.enne2.net/enne2/ilpost-podcasts-plasmoid) +- Il repository upstream resta la sorgente di riferimento per gli endpoint non ufficiali de Il Post; questo plasmoide ne riusa il modello API senza incorporare il backend web upstream. +- Gli aggiornamenti upstream non vengono importati automaticamente: vanno valutati e integrati manualmente quando cambiano endpoint o formato delle risposte. +- Il client usa gli endpoint non ufficiali autenticati de Il Post (`/user/v1/auth/login` e `/podcast/v1/podcast`). Usarlo solo con un account autorizzato e nel rispetto dei Termini di servizio. + +## Funzionalità + +- Elenco degli episodi più recenti dei programmi disponibili. +- Programmi preferiti selezionabili dalle impostazioni Plasma e mostrati con priorità. +- Copertine asincrone con cache e icona di fallback se l'artwork non è disponibile. +- Login dal widget; username, password e token vengono conservati in KWallet tramite `kwallet-query`, non in KConfig o nei file del progetto. +- Riproduzione con Dragon Player, player KDE che espone MPRIS (`org.mpris.MediaPlayer2.dragonplayer`), con fallback al player predefinito tramite `xdg-open`. +- Aggiornamento periodico configurabile e filtro “solo preferiti”. + +## Struttura + +```text +package/ + metadata.json + contents/config/ # impostazioni Plasma 6 + contents/ui/ # interfaccia QML e catalogo programmi + contents/lib/ # plugin QML compilato durante la build +src/ # client API/KWallet e plugin QML C++ +CMakeLists.txt +``` + +Il codice è volutamente standalone: `package/` è il pacchetto Plasma, mentre `src/` contiene il bridge C++ per rete, autenticazione, KWallet e avvio del player. + +## Compilazione e installazione locale + +Dipendenze: Qt 6 Core/Network/Qml, `kwallet-query`, `kpackagetool6` e Dragon Player (oppure un handler audio compatibile). + +```bash +cmake -S . -B build -DCMAKE_BUILD_TYPE=Release +cmake --build build +cmake --build build --target install-plasmoid +``` + +Per una prima installazione: + +```bash +kpackagetool6 -t Plasma/Applet -i package +``` + +Test rapido: + +```bash +plasmawindowed org.enne2.ilpost.podcasts +``` + +Dopo l'installazione, aggiungere **Podcast de Il Post** al desktop e aprire **Configura** per scegliere preferiti, quantità e intervallo di aggiornamento. Le credenziali vanno inserite nei campi del widget, non nella shell o nei file Git. + +## Sviluppo upstream + +Il repository upstream può essere registrato come remoto di sola lettura: + +```bash +git remote add upstream https://github.com/thekoma/ilpostapi.git +git fetch upstream --prune +``` + +`origin` deve rimanere il repository Gitea del plasmoide. Prima di integrare modifiche upstream, verificare manualmente endpoint, autenticazione, campi JSON e Termini di servizio. + +## Sicurezza e limiti + +- Non committare credenziali, token, file `.env` o dump delle risposte API. +- KWallet deve essere disponibile nella sessione KDE; se non lo è, il login non può salvare i segreti. +- Le API usate non sono un contratto pubblico stabile e possono cambiare senza preavviso. +- L'audio e le copertine possono mancare per alcuni episodi o dipendere dai permessi dell'account. + +## Licenza + +Il codice del plasmoide è distribuito con licenza GPL-3.0-or-later, come indicato nei metadati del pacchetto. L'attribuzione all'upstream resta esplicita sopra; verificare sempre gli obblighi applicabili prima di redistribuire o modificare il progetto. diff --git a/package/contents/config/config.qml b/package/contents/config/config.qml new file mode 100644 index 0000000..7404fb6 --- /dev/null +++ b/package/contents/config/config.qml @@ -0,0 +1,10 @@ +import QtQuick +import org.kde.plasma.configuration + +ConfigModel { + ConfigCategory { + name: i18n("Podcast") + icon: "audio-x-generic" + source: "configGeneral.qml" + } +} diff --git a/package/contents/config/configGeneral.qml b/package/contents/config/configGeneral.qml new file mode 100644 index 0000000..0bd6b19 --- /dev/null +++ b/package/contents/config/configGeneral.qml @@ -0,0 +1,107 @@ +import QtQuick +import QtQuick.Controls as QQC2 +import QtQuick.Layouts +import org.kde.kirigami as Kirigami +import "../ui/podcastCatalog.js" as PodcastCatalog + +Kirigami.FormLayout { + id: page + + property alias cfg_favoritePodcastIds: favoriteIds.text + property alias cfg_maxEpisodes: maxEpisodes.value + property alias cfg_refreshIntervalSec: refreshInterval.value + property alias cfg_favoritesOnly: favoritesOnly.checked + + function selectedIds() { + return favoriteIds.text.split(",").map(function (id) { return id.trim() }).filter(function (id) { return id.length > 0 }) + } + + function hasId(id) { + return selectedIds().indexOf(String(id)) >= 0 + } + + function toggleId(id, enabled) { + var ids = selectedIds() + var value = String(id) + var index = ids.indexOf(value) + if (enabled && index < 0) + ids.push(value) + else if (!enabled && index >= 0) + ids.splice(index, 1) + favoriteIds.text = ids.join(",") + } + + QQC2.TextField { + id: favoriteIds + Kirigami.FormData.label: i18n("Preferiti:") + placeholderText: i18n("Seleziona dall'elenco sotto") + visible: false + } + + QQC2.Label { + Kirigami.FormData.label: i18n("Programmi:") + text: i18n("Seleziona i programmi da mettere in cima alla lista.") + wrapMode: Text.WordWrap + Layout.maximumWidth: 440 + } + + Rectangle { + Kirigami.FormData.label: "" + Layout.preferredWidth: 440 + Layout.preferredHeight: 240 + color: Kirigami.Theme.backgroundColor + border.color: Kirigami.Theme.disabledTextColor + radius: 4 + + QQC2.ScrollView { + anchors.fill: parent + anchors.margins: 6 + clip: true + + Column { + width: parent.width + spacing: 2 + + Repeater { + model: PodcastCatalog.catalog + delegate: QQC2.CheckBox { + width: parent.width + text: modelData.title + " (" + modelData.id + ")" + checked: page.hasId(modelData.id) + onClicked: page.toggleId(modelData.id, checked) + } + } + } + } + } + + QQC2.SpinBox { + id: maxEpisodes + Kirigami.FormData.label: i18n("Episodi da mostrare:") + from: 1 + to: 30 + } + + QQC2.SpinBox { + id: refreshInterval + Kirigami.FormData.label: i18n("Aggiornamento (secondi):") + from: 120 + to: 86400 + stepSize: 60 + editable: true + } + + QQC2.CheckBox { + id: favoritesOnly + Kirigami.FormData.label: i18n("Filtro:") + text: i18n("Mostra solo i preferiti") + } + + QQC2.Label { + Kirigami.FormData.label: "" + text: i18n("Le credenziali vengono richieste nel widget e conservate in KWallet, non nella configurazione Plasma.") + wrapMode: Text.WordWrap + Layout.maximumWidth: 440 + color: Kirigami.Theme.disabledTextColor + } +} diff --git a/package/contents/config/main.xml b/package/contents/config/main.xml new file mode 100644 index 0000000..b942642 --- /dev/null +++ b/package/contents/config/main.xml @@ -0,0 +1,26 @@ + + + + + + + + + + 8 + 1 + 30 + + + + 900 + 120 + 86400 + + + + false + + + + diff --git a/package/contents/lib/qmldir b/package/contents/lib/qmldir new file mode 100644 index 0000000..21e8cf9 --- /dev/null +++ b/package/contents/lib/qmldir @@ -0,0 +1,2 @@ +module org.enne2.ilpost.credentials +plugin ilpostcredentialsplugin diff --git a/package/contents/ui/main.qml b/package/contents/ui/main.qml new file mode 100644 index 0000000..486ac84 --- /dev/null +++ b/package/contents/ui/main.qml @@ -0,0 +1,277 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls as QQC2 +import org.kde.plasma.plasmoid +import org.kde.plasma.core as PlasmaCore +import org.kde.plasma.components 3.0 as PC3 +import org.kde.kirigami as Kirigami +import "../lib" as Credentials + +PlasmoidItem { + id: root + + property string favoriteIds: String(Plasmoid.configuration.favoritePodcastIds || "") + property int maxEpisodes: Number(Plasmoid.configuration.maxEpisodes || 8) + property int refreshSeconds: Number(Plasmoid.configuration.refreshIntervalSec || 900) + property bool favoritesOnly: Boolean(Plasmoid.configuration.favoritesOnly) + + Credentials.IlPostApiClient { + id: api + } + + Plasmoid.title: i18n("Podcast de Il Post") + Plasmoid.icon: "audio-x-generic" + Plasmoid.backgroundHints: PlasmaCore.Types.DefaultBackground + preferredRepresentation: fullRepresentation + switchWidth: 320 + switchHeight: 360 + + function refresh() { + api.refresh(root.favoriteIds, root.maxEpisodes, root.favoritesOnly) + } + + onFavoriteIdsChanged: if (api.authenticated) refresh() + onMaxEpisodesChanged: if (api.authenticated) refresh() + onFavoritesOnlyChanged: if (api.authenticated) refresh() + + Component.onCompleted: api.restore() + + Connections { + target: api + function onAuthenticatedChanged() { + if (api.authenticated) + root.refresh() + } + } + + Timer { + interval: Math.max(120, root.refreshSeconds) * 1000 + repeat: true + running: api.authenticated + onTriggered: root.refresh() + } + + compactRepresentation: Component { + Item { + implicitWidth: Kirigami.Units.iconSizes.smallMedium + implicitHeight: Kirigami.Units.iconSizes.smallMedium + Kirigami.Icon { + anchors.fill: parent + source: Plasmoid.icon + color: api.authenticated ? Kirigami.Theme.textColor : Kirigami.Theme.disabledTextColor + } + MouseArea { + anchors.fill: parent + onClicked: Plasmoid.expanded = !Plasmoid.expanded + } + } + } + + fullRepresentation: Component { + Item { + implicitWidth: 380 + implicitHeight: 430 + Layout.minimumWidth: 300 + + Connections { + target: api + function onLoginSucceeded() { + passwordField.clear() + } + } + Layout.minimumHeight: 260 + Layout.preferredWidth: 380 + Layout.preferredHeight: 430 + + ColumnLayout { + anchors.fill: parent + anchors.margins: Kirigami.Units.smallSpacing + spacing: Kirigami.Units.smallSpacing + + RowLayout { + Layout.fillWidth: true + PC3.Label { + text: i18n("Podcast de Il Post") + font.bold: true + font.pointSize: Kirigami.Theme.defaultFont.pointSize + 2 + Layout.fillWidth: true + elide: Text.ElideRight + } + PC3.Button { + icon.name: "view-refresh" + display: QQC2.AbstractButton.IconOnly + enabled: !api.loading && api.authenticated + onClicked: root.refresh() + QQC2.ToolTip.text: i18n("Aggiorna") + QQC2.ToolTip.visible: hovered + } + } + + PC3.Label { + Layout.fillWidth: true + visible: api.statusMessage.length > 0 + text: api.statusMessage + color: api.authenticated ? Kirigami.Theme.disabledTextColor : Kirigami.Theme.negativeTextColor + elide: Text.ElideRight + } + + ColumnLayout { + visible: !api.authenticated + Layout.fillWidth: true + spacing: Kirigami.Units.smallSpacing + + PC3.Label { + Layout.fillWidth: true + text: i18n("Accedi per visualizzare gli episodi. La password sarà salvata solo in KWallet.") + wrapMode: Text.WordWrap + } + QQC2.TextField { + id: emailField + Layout.fillWidth: true + placeholderText: i18n("Username / email Il Post") + inputMethodHints: Qt.ImhEmailCharactersOnly + enabled: !api.loading + } + QQC2.TextField { + id: passwordField + Layout.fillWidth: true + placeholderText: i18n("Password") + echoMode: TextInput.Password + enabled: !api.loading + onAccepted: loginButton.clicked() + } + PC3.Button { + id: loginButton + Layout.fillWidth: true + text: api.loading ? i18n("Accesso…") : i18n("Accedi e salva in KWallet") + enabled: !api.loading && emailField.text.length > 0 && passwordField.text.length > 0 + onClicked: api.login(emailField.text, passwordField.text) + } + } + + QQC2.ScrollView { + visible: api.authenticated + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + + ListView { + id: episodeList + anchors.fill: parent + spacing: Kirigami.Units.smallSpacing + model: api.episodes + delegate: Rectangle { + required property var modelData + width: episodeList.width + implicitHeight: episodeColumn.implicitHeight + 16 + radius: 5 + color: modelData.favorite + ? Qt.rgba(0.25, 0.45, 0.25, 0.16) + : Kirigami.Theme.backgroundColor + border.color: modelData.favorite + ? Kirigami.Theme.positiveTextColor + : Kirigami.Theme.disabledTextColor + border.width: 1 + + RowLayout { + id: episodeRow + anchors.fill: parent + anchors.margins: 8 + spacing: Kirigami.Units.smallSpacing + + Item { + Layout.preferredWidth: 64 + Layout.preferredHeight: 64 + Layout.alignment: Qt.AlignTop + + Image { + id: coverImage + anchors.fill: parent + source: modelData.podcastImage || "" + asynchronous: true + cache: true + fillMode: Image.PreserveAspectCrop + smooth: true + mipmap: true + visible: status === Image.Ready + } + + Kirigami.Icon { + anchors.centerIn: parent + width: 36 + height: 36 + source: "audio-x-generic" + visible: !coverImage.visible + } + + Rectangle { + anchors.fill: parent + color: "transparent" + border.width: 1 + border.color: Kirigami.Theme.disabledTextColor + opacity: 0.45 + } + } + + ColumnLayout { + id: episodeColumn + Layout.fillWidth: true + spacing: 2 + + RowLayout { + Layout.fillWidth: true + PC3.Label { + text: modelData.podcastTitle || i18n("Podcast") + color: modelData.favorite ? Kirigami.Theme.positiveTextColor : Kirigami.Theme.disabledTextColor + font.bold: true + font.pointSize: Kirigami.Theme.defaultFont.pointSize - 1 + Layout.fillWidth: true + elide: Text.ElideRight + } + PC3.Label { + visible: modelData.favorite + text: i18n("Preferito") + color: Kirigami.Theme.positiveTextColor + font.pointSize: Kirigami.Theme.defaultFont.pointSize - 2 + } + } + PC3.Label { + Layout.fillWidth: true + text: modelData.title || i18n("Senza titolo") + font.bold: true + wrapMode: Text.WordWrap + } + PC3.Label { + Layout.fillWidth: true + text: modelData.date ? new Date(modelData.date).toLocaleDateString(Qt.locale()) : "" + color: Kirigami.Theme.disabledTextColor + font.pointSize: Kirigami.Theme.defaultFont.pointSize - 2 + } + PC3.Button { + visible: Boolean(modelData.audioUrl) + text: i18n("Riproduci in Dragon Player") + onClicked: api.playAudio(modelData.audioUrl) + } + } + } + } + } + } + + RowLayout { + visible: api.authenticated + Layout.fillWidth: true + PC3.Label { + text: i18n("%1 episodi · preferiti in cima", api.episodes.length) + color: Kirigami.Theme.disabledTextColor + Layout.fillWidth: true + } + PC3.Button { + text: i18n("Disconnetti") + onClicked: api.clearCredentials() + } + } + } + } + } +} diff --git a/package/contents/ui/podcastCatalog.js b/package/contents/ui/podcastCatalog.js new file mode 100644 index 0000000..e04d063 --- /dev/null +++ b/package/contents/ui/podcastCatalog.js @@ -0,0 +1 @@ +var catalog = [{"id":227193,"title":"Tienimi Bordone"},{"id":233679,"title":"Timbuctu"},{"id":232180,"title":"Globo"},{"id":227474,"title":"Morning"},{"id":236472,"title":"Sigmund"},{"id":235598,"title":"L'invasione"},{"id":236670,"title":"Altre Indagini"},{"id":233120,"title":"Comodino"},{"id":233226,"title":"Amare parole"},{"id":234755,"title":"Per fare il Post"},{"id":230388,"title":"Ci vuole una scienza"},{"id":230293,"title":"Indagini"},{"id":231758,"title":"Tienimi Morning"},{"id":235926,"title":"In soldoni"},{"id":227196,"title":"Un podcast su Sanremo"},{"id":227412,"title":"Audiopost"},{"id":235470,"title":"Wild Baricco"},{"id":234841,"title":"10 risposte sui migranti"},{"id":234250,"title":"L'ombelico di un mondo"},{"id":227194,"title":"Joypad"},{"id":232871,"title":"Cosa c'entra?"},{"id":234368,"title":"Ripassini"},{"id":234296,"title":"Tredici"},{"id":229701,"title":"Politics"},{"id":233678,"title":"Vicini e lontani"},{"id":233516,"title":"Il grande vecchio"},{"id":233318,"title":"Le onorate"},{"id":228236,"title":"M49 - Una storia di orsi e persone"},{"id":233144,"title":"La nave"},{"id":228710,"title":"La fine del mondo"},{"id":232619,"title":"L'erba del vicino"},{"id":231622,"title":"Le basi"},{"id":231088,"title":"La bomba"},{"id":231013,"title":"Un furto di quart'ordine"},{"id":232518,"title":"Racconti a Natale"},{"id":230754,"title":"La fabbrica dei soldi"},{"id":230476,"title":"Certe cose"},{"id":229228,"title":"Cose spiegate bene"},{"id":227496,"title":"Un podcast sull'Eurovision"},{"id":227768,"title":"Da Vermicino in poi"},{"id":227322,"title":"Mai più"},{"id":227244,"title":"La guerra dei gelati"},{"id":227259,"title":"L'anno del jazz"},{"id":228752,"title":"Strade blu"},{"id":227246,"title":"Immaginare l'apocalisse"},{"id":227195,"title":"Konrad"},{"id":227197,"title":"Storie dalla libreria"},{"id":227198,"title":"Weekly Post"},{"id":227736,"title":"Wembley"}]; diff --git a/package/metadata.json b/package/metadata.json new file mode 100644 index 0000000..ff6794e --- /dev/null +++ b/package/metadata.json @@ -0,0 +1,16 @@ +{ + "KPackageStructure": "Plasma/Applet", + "KPlugin": { + "Id": "org.enne2.ilpost.podcasts", + "Name": "Il Post Podcasts", + "Name[it]": "Podcast de Il Post", + "Description": "Visualizza gli episodi più recenti dei podcast de Il Post", + "Description[it]": "Visualizza gli episodi più recenti dei podcast de Il Post", + "Icon": "audio-x-generic", + "Category": "Multimedia", + "License": "GPL-3.0-or-later", + "Version": "0.1.0", + "Authors": [{ "Name": "enne2" }] + }, + "X-Plasma-API-Minimum-Version": "6.0" +} diff --git a/src/ilpostapiclient.cpp b/src/ilpostapiclient.cpp new file mode 100644 index 0000000..2d6c74e --- /dev/null +++ b/src/ilpostapiclient.cpp @@ -0,0 +1,357 @@ +#include "ilpostapiclient.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +QStringList walletArguments(const QString &operation, const QString &key) +{ + return {QStringLiteral("-f"), QStringLiteral("Passwords"), operation, key, + QStringLiteral("kdewallet")}; +} + +QString episodeDate(const QVariantMap &episode) +{ + return episode.value(QStringLiteral("date")).toString(); +} +} + +IlPostApiClient::IlPostApiClient(QObject *parent) + : QObject(parent) +{ +} + +QString IlPostApiClient::walletRead(const QString &key) const +{ + QProcess process; + process.start(QStringLiteral("kwallet-query"), walletArguments(QStringLiteral("-r"), key)); + if (!process.waitForFinished(15000) || process.exitCode() != 0) { + return {}; + } + return QString::fromUtf8(process.readAllStandardOutput()).trimmed(); +} + +bool IlPostApiClient::walletWrite(const QString &key, const QString &value) const +{ + QProcess process; + process.start(QStringLiteral("kwallet-query"), walletArguments(QStringLiteral("-w"), key)); + if (!process.waitForStarted(3000)) { + return false; + } + process.write(value.toUtf8()); + process.closeWriteChannel(); + return process.waitForFinished(15000) && process.exitCode() == 0; +} + +void IlPostApiClient::walletRemove(const QString &key) const +{ + // kwallet-query has no portable delete operation; overwrite is avoided and + // the user can remove the namespaced entries from KDE Wallet Manager. + Q_UNUSED(key) +} + +void IlPostApiClient::setLoading(bool value) +{ + if (m_loading == value) { + return; + } + m_loading = value; + emit loadingChanged(); +} + +void IlPostApiClient::setAuthenticated(bool value) +{ + if (m_authenticated == value) { + return; + } + m_authenticated = value; + emit authenticatedChanged(); +} + +void IlPostApiClient::setStatus(const QString &value) +{ + if (m_statusMessage == value) { + return; + } + m_statusMessage = value; + emit statusMessageChanged(); +} + +void IlPostApiClient::restore() +{ + const QString token = walletRead(QString::fromUtf8(tokenKey)); + m_email = walletRead(QString::fromUtf8(emailKey)); + m_password = walletRead(QString::fromUtf8(passwordKey)); + m_credentialsStored = !m_email.isEmpty() && !m_password.isEmpty(); + emit credentialsStoredChanged(); + + if (!token.isEmpty()) { + m_token = token; + setAuthenticated(true); + setStatus(QStringLiteral("Sessione ripristinata da KWallet")); + return; + } + + if (m_credentialsStored) { + loginInternal(m_email, m_password, {}); + } else { + setStatus(QStringLiteral("Configura le credenziali di Il Post")); + } +} + +void IlPostApiClient::login(const QString &email, const QString &password) +{ + const QString cleanEmail = email.trimmed(); + if (cleanEmail.isEmpty() || password.isEmpty()) { + setStatus(QStringLiteral("Inserisci username e password")); + emit loginFailed(); + return; + } + + setLoading(true); + setStatus(QStringLiteral("Autenticazione in corso…")); + loginInternal(cleanEmail, password, [this] { + fetchPodcasts(false); + }); +} + +void IlPostApiClient::loginInternal(const QString &email, const QString &password, + const std::function &onSuccess) +{ + QNetworkRequest request(QUrl(QString::fromUtf8(loginUrl))); + request.setHeader(QNetworkRequest::ContentTypeHeader, + QStringLiteral("application/x-www-form-urlencoded")); + QUrlQuery form; + form.addQueryItem(QStringLiteral("username"), email); + form.addQueryItem(QStringLiteral("password"), password); + + QNetworkReply *reply = m_network.post(request, form.toString(QUrl::FullyEncoded).toUtf8()); + connect(reply, &QNetworkReply::finished, this, [this, reply, email, password, onSuccess] { + const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + const QJsonDocument document = QJsonDocument::fromJson(reply->readAll()); + reply->deleteLater(); + + if (status < 200 || status >= 300 || !document.isObject()) { + setLoading(false); + setAuthenticated(false); + setStatus(QStringLiteral("Login non riuscito (HTTP %1)").arg(status)); + emit loginFailed(); + return; + } + + const QJsonObject data = document.object().value(QStringLiteral("data")).toObject(); + const QString token = data.value(QStringLiteral("data")).toObject() + .value(QStringLiteral("token")).toString(); + if (token.isEmpty()) { + setLoading(false); + setAuthenticated(false); + setStatus(QStringLiteral("Risposta di login senza token")); + emit loginFailed(); + return; + } + + m_email = email; + m_password = password; + m_token = token; + const bool stored = walletWrite(QString::fromUtf8(emailKey), email) + && walletWrite(QString::fromUtf8(passwordKey), password) + && walletWrite(QString::fromUtf8(tokenKey), token); + m_credentialsStored = stored; + emit credentialsStoredChanged(); + setAuthenticated(true); + setStatus(stored ? QStringLiteral("Credenziali salvate in KWallet") + : QStringLiteral("Login riuscito; KWallet non disponibile")); + emit loginSucceeded(); + if (onSuccess) { + onSuccess(); + } + }); +} + +void IlPostApiClient::refresh(const QString &favoriteIds, int maxEpisodes, bool favoritesOnly) +{ + m_favoriteIds.clear(); + for (const QString &id : favoriteIds.split(',', Qt::SkipEmptyParts)) { + m_favoriteIds.insert(id.trimmed()); + } + m_maxEpisodes = std::clamp(maxEpisodes, 1, 30); + m_favoritesOnly = favoritesOnly; + + if (m_token.isEmpty()) { + restore(); + if (m_token.isEmpty()) { + return; + } + } + fetchPodcasts(true); +} + +void IlPostApiClient::fetchPodcasts(bool retryLogin) +{ + setLoading(true); + setStatus(QStringLiteral("Recupero programmi…")); + QNetworkRequest request(QUrl(QStringLiteral("%1/?pg=1&hits=10000").arg(QString::fromUtf8(apiBase)))); + request.setRawHeader("Apikey", apiKey); + request.setRawHeader("Token", m_token.toUtf8()); + + QNetworkReply *reply = m_network.get(request); + connect(reply, &QNetworkReply::finished, this, [this, reply, retryLogin] { + const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + const QByteArray body = reply->readAll(); + reply->deleteLater(); + if (status == 401 && retryLogin && !m_email.isEmpty() && !m_password.isEmpty()) { + loginInternal(m_email, m_password, [this] { fetchPodcasts(false); }); + return; + } + if (status < 200 || status >= 300) { + setLoading(false); + setStatus(QStringLiteral("API non raggiungibile (HTTP %1)").arg(status)); + return; + } + + const QJsonDocument document = QJsonDocument::fromJson(body); + const QJsonArray data = document.object().value(QStringLiteral("data")).toArray(); + m_podcasts.clear(); + for (const QJsonValue &value : data) { + const QJsonObject podcast = value.toObject(); + QVariantMap item; + item.insert(QStringLiteral("id"), podcast.value(QStringLiteral("id")).toInt()); + item.insert(QStringLiteral("title"), podcast.value(QStringLiteral("title")).toString()); + item.insert(QStringLiteral("image"), podcast.value(QStringLiteral("image")).toString()); + item.insert(QStringLiteral("accessLevel"), podcast.value(QStringLiteral("access_level")).toString()); + m_podcasts.append(item); + } + emit podcastsChanged(); + fetchLatestEpisodes(retryLogin); + }); +} + +void IlPostApiClient::fetchLatestEpisodes(bool retryLogin) +{ + m_episodes.clear(); + m_pendingEpisodes = 0; + for (const QVariant &value : m_podcasts) { + const QVariantMap podcast = value.toMap(); + const int id = podcast.value(QStringLiteral("id")).toInt(); + QNetworkRequest request(QUrl(QStringLiteral("%1/%2/?pg=1&hits=1") + .arg(QString::fromUtf8(apiBase)) + .arg(id))); + request.setRawHeader("Apikey", apiKey); + request.setRawHeader("Token", m_token.toUtf8()); + QNetworkReply *reply = m_network.get(request); + ++m_pendingEpisodes; + connect(reply, &QNetworkReply::finished, this, [this, reply, podcast, retryLogin] { + const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + const QByteArray body = reply->readAll(); + reply->deleteLater(); + if (status == 401 && retryLogin && !m_email.isEmpty() && !m_password.isEmpty()) { + --m_pendingEpisodes; + if (m_pendingEpisodes <= 0) { + loginInternal(m_email, m_password, [this] { fetchPodcasts(false); }); + } + return; + } + if (status >= 200 && status < 300) { + const QJsonArray data = QJsonDocument::fromJson(body).object() + .value(QStringLiteral("data")).toArray(); + if (!data.isEmpty()) { + const QJsonObject episode = data.first().toObject(); + QVariantMap item; + item.insert(QStringLiteral("podcastId"), podcast.value(QStringLiteral("id"))); + item.insert(QStringLiteral("podcastTitle"), podcast.value(QStringLiteral("title"))); + item.insert(QStringLiteral("podcastImage"), podcast.value(QStringLiteral("image"))); + item.insert(QStringLiteral("id"), episode.value(QStringLiteral("id")).toInt()); + item.insert(QStringLiteral("title"), episode.value(QStringLiteral("title")).toString()); + item.insert(QStringLiteral("description"), episode.value(QStringLiteral("description")).toString()); + item.insert(QStringLiteral("date"), episode.value(QStringLiteral("date")).toString()); + item.insert(QStringLiteral("duration"), episode.value(QStringLiteral("milliseconds")).toInt()); + item.insert(QStringLiteral("audioUrl"), episode.value(QStringLiteral("episode_raw_url")).toString()); + item.insert(QStringLiteral("favorite"), m_favoriteIds.contains(QString::number(item.value(QStringLiteral("podcastId")).toInt()))); + m_episodes.append(item); + } + } + --m_pendingEpisodes; + if (m_pendingEpisodes <= 0) { + finishEpisodes(); + } + }); + } + if (m_pendingEpisodes == 0) { + finishEpisodes(); + } +} + +void IlPostApiClient::finishEpisodes() +{ + std::sort(m_episodes.begin(), m_episodes.end(), [](const QVariant &left, const QVariant &right) { + const QVariantMap a = left.toMap(); + const QVariantMap b = right.toMap(); + const bool favoriteA = a.value(QStringLiteral("favorite")).toBool(); + const bool favoriteB = b.value(QStringLiteral("favorite")).toBool(); + if (favoriteA != favoriteB) { + return favoriteA; + } + return episodeDate(a) > episodeDate(b); + }); + + if (m_favoritesOnly) { + QVariantList favorites; + for (const QVariant &episode : m_episodes) { + if (episode.toMap().value(QStringLiteral("favorite")).toBool()) { + favorites.append(episode); + } + } + m_episodes = favorites; + } + while (m_episodes.size() > m_maxEpisodes) { + m_episodes.removeLast(); + } + emit episodesChanged(); + setLoading(false); + setStatus(m_episodes.isEmpty() ? QStringLiteral("Nessun episodio disponibile") + : QStringLiteral("Aggiornato: %1 episodi").arg(m_episodes.size())); +} + +bool IlPostApiClient::playAudio(const QString &audioUrl) +{ + const QUrl url(audioUrl.trimmed()); + if (!url.isValid() || url.isEmpty()) { + setStatus(QStringLiteral("URL audio non valida")); + return false; + } + + if (QProcess::startDetached(QStringLiteral("dragon"), {url.toString()})) { + setStatus(QStringLiteral("Riproduzione avviata in Dragon Player (MPRIS)")); + return true; + } + + if (QProcess::startDetached(QStringLiteral("xdg-open"), {url.toString()})) { + setStatus(QStringLiteral("Riproduzione avviata nel player predefinito")); + return true; + } + + setStatus(QStringLiteral("Nessun player audio disponibile")); + return false; +} + +void IlPostApiClient::clearCredentials() +{ + // The CLI intentionally does not expose a delete operation consistently + // across versions. Re-login overwrites the entries; clear the live session. + m_token.clear(); + m_email.clear(); + m_password.clear(); + m_episodes.clear(); + emit episodesChanged(); + m_credentialsStored = false; + emit credentialsStoredChanged(); + setAuthenticated(false); + setStatus(QStringLiteral("Sessione disconnessa; rimuovi le voci KWallet se necessario")); +} diff --git a/src/ilpostapiclient.h b/src/ilpostapiclient.h new file mode 100644 index 0000000..030abdf --- /dev/null +++ b/src/ilpostapiclient.h @@ -0,0 +1,86 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +class QNetworkReply; + +class IlPostApiClient : public QObject +{ + Q_OBJECT + Q_PROPERTY(QVariantList podcasts READ podcasts NOTIFY podcastsChanged) + Q_PROPERTY(QVariantList episodes READ episodes NOTIFY episodesChanged) + Q_PROPERTY(bool loading READ loading NOTIFY loadingChanged) + Q_PROPERTY(bool authenticated READ authenticated NOTIFY authenticatedChanged) + Q_PROPERTY(QString statusMessage READ statusMessage NOTIFY statusMessageChanged) + Q_PROPERTY(bool credentialsStored READ credentialsStored NOTIFY credentialsStoredChanged) + +public: + explicit IlPostApiClient(QObject *parent = nullptr); + + QVariantList podcasts() const { return m_podcasts; } + QVariantList episodes() const { return m_episodes; } + bool loading() const { return m_loading; } + bool authenticated() const { return m_authenticated; } + QString statusMessage() const { return m_statusMessage; } + bool credentialsStored() const { return m_credentialsStored; } + + Q_INVOKABLE void restore(); + Q_INVOKABLE void login(const QString &email, const QString &password); + Q_INVOKABLE void refresh(const QString &favoriteIds, int maxEpisodes, bool favoritesOnly); + Q_INVOKABLE void clearCredentials(); + Q_INVOKABLE bool playAudio(const QString &audioUrl); + +signals: + void podcastsChanged(); + void episodesChanged(); + void loadingChanged(); + void authenticatedChanged(); + void statusMessageChanged(); + void credentialsStoredChanged(); + void loginSucceeded(); + void loginFailed(); + +private: + static constexpr const char *apiBase = "https://api-prod.ilpost.it/podcast/v1/podcast"; + static constexpr const char *loginUrl = "https://api-prod.ilpost.it/user/v1/auth/login"; + static constexpr const char *apiKey = "testapikey"; + static constexpr const char *walletName = "kdewallet"; + static constexpr const char *walletFolder = "Passwords"; + static constexpr const char *emailKey = "org.enne2.ilpostpodcasts/email"; + static constexpr const char *passwordKey = "org.enne2.ilpostpodcasts/password"; + static constexpr const char *tokenKey = "org.enne2.ilpostpodcasts/token"; + + QString walletRead(const QString &key) const; + bool walletWrite(const QString &key, const QString &value) const; + void walletRemove(const QString &key) const; + void setLoading(bool value); + void setAuthenticated(bool value); + void setStatus(const QString &value); + void loginInternal(const QString &email, const QString &password, + const std::function &onSuccess); + void fetchPodcasts(bool retryLogin); + void fetchLatestEpisodes(bool retryLogin); + void finishEpisodes(); + void handleAuthenticationFailure(bool retryLogin, const std::function &retry); + + QNetworkAccessManager m_network; + QVariantList m_podcasts; + QVariantList m_episodes; + QSet m_favoriteIds; + QString m_email; + QString m_password; + QString m_token; + QString m_statusMessage; + int m_maxEpisodes = 8; + int m_pendingEpisodes = 0; + bool m_loading = false; + bool m_authenticated = false; + bool m_credentialsStored = false; + bool m_favoritesOnly = false; +}; diff --git a/src/plugin.cpp b/src/plugin.cpp new file mode 100644 index 0000000..eb7ad25 --- /dev/null +++ b/src/plugin.cpp @@ -0,0 +1,19 @@ +#include "ilpostapiclient.h" + +#include +#include + +class IlPostCredentialsPlugin final : public QQmlExtensionPlugin +{ + Q_OBJECT + Q_PLUGIN_METADATA(IID "org.qt-project.Qt.QQmlExtensionInterface") + +public: + void registerTypes(const char *uri) override + { + Q_ASSERT(QString::fromLatin1(uri) == QStringLiteral("org.enne2.ilpost.credentials")); + qmlRegisterType(uri, 1, 0, "IlPostApiClient"); + } +}; + +#include "plugin.moc"