feat: add KDE Plasma podcast plasmoid
This commit is contained in:
+10
@@ -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
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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.
|
||||
@@ -0,0 +1,10 @@
|
||||
import QtQuick
|
||||
import org.kde.plasma.configuration
|
||||
|
||||
ConfigModel {
|
||||
ConfigCategory {
|
||||
name: i18n("Podcast")
|
||||
icon: "audio-x-generic"
|
||||
source: "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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,2 @@
|
||||
module org.enne2.ilpost.credentials
|
||||
plugin ilpostcredentialsplugin
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"}];
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user