feat: add KDE Plasma podcast plasmoid

This commit is contained in:
Matteo Benedetto
2026-09-05 18:03:44 +02:00
commit 646033e16f
13 changed files with 1030 additions and 0 deletions
+10
View File
@@ -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
+37
View File
@@ -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"
)
+82
View File
@@ -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.
+10
View File
@@ -0,0 +1,10 @@
import QtQuick
import org.kde.plasma.configuration
ConfigModel {
ConfigCategory {
name: i18n("Podcast")
icon: "audio-x-generic"
source: "configGeneral.qml"
}
}
+107
View File
@@ -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
}
}
+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<kcfg xmlns="http://www.kde.org/standards/kcfg/1.0">
<kcfgfile name=""/>
<group name="General">
<entry name="favoritePodcastIds" type="String">
<default></default>
<label>ID dei programmi preferiti</label>
</entry>
<entry name="maxEpisodes" type="Int">
<default>8</default>
<min>1</min>
<max>30</max>
<label>Numero massimo di episodi</label>
</entry>
<entry name="refreshIntervalSec" type="Int">
<default>900</default>
<min>120</min>
<max>86400</max>
<label>Intervallo aggiornamento (secondi)</label>
</entry>
<entry name="favoritesOnly" type="Bool">
<default>false</default>
<label>Mostra solo i programmi preferiti</label>
</entry>
</group>
</kcfg>
+2
View File
@@ -0,0 +1,2 @@
module org.enne2.ilpost.credentials
plugin ilpostcredentialsplugin
+277
View File
@@ -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()
}
}
}
}
}
}
+1
View File
@@ -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"}];
+16
View File
@@ -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"
}
+357
View File
@@ -0,0 +1,357 @@
#include "ilpostapiclient.h"
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QProcess>
#include <QUrl>
#include <QUrlQuery>
#include <algorithm>
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<void()> &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"));
}
+86
View File
@@ -0,0 +1,86 @@
#pragma once
#include <QHash>
#include <QNetworkAccessManager>
#include <QObject>
#include <QSet>
#include <QStringList>
#include <QVariantList>
#include <functional>
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<void()> &onSuccess);
void fetchPodcasts(bool retryLogin);
void fetchLatestEpisodes(bool retryLogin);
void finishEpisodes();
void handleAuthenticationFailure(bool retryLogin, const std::function<void()> &retry);
QNetworkAccessManager m_network;
QVariantList m_podcasts;
QVariantList m_episodes;
QSet<QString> 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;
};
+19
View File
@@ -0,0 +1,19 @@
#include "ilpostapiclient.h"
#include <QQmlExtensionPlugin>
#include <qqml.h>
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<IlPostApiClient>(uri, 1, 0, "IlPostApiClient");
}
};
#include "plugin.moc"