1
0
Fork 0
mirror of https://github.com/HenkKalkwater/harbour-sailfin.git synced 2025-09-05 10:12:46 +00:00

Initial commit

Features so far:
- Login is working, both on back-end and GUI-wise
- Saving and reusing login tokens is working
- The home page is mostly functional
- Show details can be received and displayed in a basic manner

Following features are taken into account, but have not been fully
implemented:
- Support for multiple accounts/servers
- Securely saving login tokens
This commit is contained in:
Chris Josten 2020-09-15 16:53:13 +02:00
commit 53b3eac213
40 changed files with 2375 additions and 0 deletions

58
src/credentialmanager.cpp Normal file
View file

@ -0,0 +1,58 @@
#include "credentialmanager.h"
CredentialsManager * CredentialsManager::getInstance(QObject *parent) {
return new FallbackCredentialsManager(parent);
}
////////////////////////////////////////////////////////////////////////////////////////////////////
// FallbackCredentialsManager //
////////////////////////////////////////////////////////////////////////////////////////////////////
FallbackCredentialsManager::FallbackCredentialsManager(QObject *parent)
: CredentialsManager (parent) {
m_settings.beginGroup("Credentials");
}
QString FallbackCredentialsManager::urlToGroupName(const QString &url) const {
// |'s are not allowed in URLS, but are in group names.
return QString::number(qHash(url), 16);
}
QString FallbackCredentialsManager::groupNameToUrl(const QString &group) const {
QString tmp = QString(group);
return tmp.replace('|', "/");
}
void FallbackCredentialsManager::store(const QString &server, const QString &user, const QString &token) {
m_settings.setValue(urlToGroupName(server) + "/users/" + user + "/accessToken", token);
m_settings.setValue(urlToGroupName(server) + "/address", server);
}
void FallbackCredentialsManager::get(const QString &server, const QString &user) const {
QString result = m_settings.value(urlToGroupName(server) + "/users/" + user + "/accessToken").toString();
emit CredentialsManager::tokenRetrieved(server, user, result);
}
void FallbackCredentialsManager::remove(const QString &server, const QString &user) {
m_settings.remove(urlToGroupName(server) + "/" + user);
}
void FallbackCredentialsManager::listServers() const {
QList<QString> keys = m_settings.childGroups();
qDebug() << "Servers: " << keys;
for (int i = 0; i < keys.size(); i++) {
keys[i] = m_settings.value(keys[i] + "/address").toString();
}
qDebug() << "Servers: " << keys;
emit CredentialsManager::serversListed(keys);
}
void FallbackCredentialsManager::listUsers(const QString &server) {
m_settings.beginGroup(urlToGroupName(server));
m_settings.beginGroup("users");
QStringList users = m_settings.childGroups();
qDebug() << "Users: " << users;
m_settings.endGroup();
m_settings.endGroup();
emit CredentialsManager::usersListed(users);
}

102
src/credentialmanager.h Normal file
View file

@ -0,0 +1,102 @@
#ifndef CREDENTIALS_MANAGER_H
#define CREDENTIALS_MANAGER_H
#include <QDebug>
#include <QHash>
#include <QObject>
#include <QSettings>
#include <QString>
class CredentialsManager : public QObject {
Q_OBJECT
public:
/**
* @brief Stores a token
* @param server The server to store the token for
* @param user The user to store the token for.
* @param token The token to store.
*/
virtual void store(const QString &server, const QString &user, const QString &token) {
Q_UNUSED(server)
Q_UNUSED(user)
Q_UNUSED(token)
Q_UNIMPLEMENTED();
}
/**
* @brief Retrieves a stored token. Emits tokenRetrieved when the token is retrieved.
* @param server The serverId to retrieve the token from.
* @param user The user to retrieve the token for
*/
virtual void get(const QString &server, const QString &user) const {
Q_UNUSED(server)
Q_UNUSED(user)
Q_UNIMPLEMENTED();
}
/**
* @brief removes a token
* @param server
* @param user
*/
virtual void remove(const QString &server, const QString &user) {
Q_UNUSED(server)
Q_UNUSED(user)
Q_UNIMPLEMENTED();
}
/**
* @brief Gives the list of servers that have a user stored with a token.
*/
virtual void listServers() const { Q_UNIMPLEMENTED(); }
/**
* @brief List the users with a token on a server
* @param server
*/
virtual void listUsers(const QString &server) {
Q_UNUSED(server)
Q_UNIMPLEMENTED();
}
/**
* @brief Retrieves an implementation which can store this token.
* @param The parent to set the implementations QObject parent to
* @return An implementation of this interface (may vary acrros platform).
*/
static CredentialsManager *getInstance(QObject *parent = nullptr);
/**
* @return if the implementation of this interface stores the token in a secure place.
*/
virtual bool isSecure() const { return false; }
signals:
void tokenRetrieved(const QString &server, const QString &user, const QString &token) const;
void serversListed(const QStringList &servers) const;
void usersListed(const QStringList &users) const;
protected:
explicit CredentialsManager(QObject *parent = nullptr) : QObject (parent) {}
};
/**
* @brief Implementation of CredentialsManager that stores credentials in plain-text
*/
class FallbackCredentialsManager : public CredentialsManager {
Q_OBJECT
public:
FallbackCredentialsManager(QObject *parent = nullptr);
void store(const QString &server, const QString &user, const QString &token) override;
void get(const QString &server, const QString &user) const override;
void remove(const QString &server, const QString &user) override;
void listServers() const override;
void listUsers(const QString &server) override;
bool isSecure() const override { return false; }
private:
QString urlToGroupName(const QString &url) const;
QString groupNameToUrl(const QString &group) const;
QSettings m_settings;
};
#endif

48
src/harbour-sailfin.cpp Normal file
View file

@ -0,0 +1,48 @@
#ifdef QT_QML_DEBUG
#include <QtQuick>
#endif
#include <QJSEngine>
#include <QGuiApplication>
#include <QQuickView>
#include <QQmlEngine>
#include <sailfishapp.h>
#include "jellyfinapiclient.h"
#include "jellyfinapimodel.h"
#include "serverdiscoverymodel.h"
void registerQml() {
const char* QML_NAMESPACE = "nl.netsoj.chris.Jellyfin";
qmlRegisterSingletonType<JellyfinApiClient>(QML_NAMESPACE, 1, 0, "ApiClient", [](QQmlEngine *eng, QJSEngine *js) {
Q_UNUSED(eng)
Q_UNUSED(js)
return dynamic_cast<QObject*>(new JellyfinApiClient());
});
qmlRegisterType<ServerDiscoveryModel>(QML_NAMESPACE, 1, 0, "ServerDiscoveryModel");
// API models
Jellyfin::registerModels(QML_NAMESPACE);
}
int main(int argc, char *argv[]) {
// SailfishApp::main() will display "qml/harbour-sailfin.qml", if you need more
// control over initialization, you can use:
//
// - SailfishApp::application(int, char *[]) to get the QGuiApplication *
// - SailfishApp::createView() to get a new QQuickView * instance
// - SailfishApp::pathTo(QString) to get a QUrl to a resource file
// - SailfishApp::pathToMainQml() to get a QUrl to the main QML file
//
// To display the view, call "show()" (will show fullscreen on device).
QGuiApplication *app = SailfishApp::application(argc, argv);
registerQml();
QQuickView *view = SailfishApp::createView();
view->setSource(SailfishApp::pathToMainQml());
view->show();
return app->exec();
}

214
src/jellyfinapiclient.cpp Normal file
View file

@ -0,0 +1,214 @@
#include "jellyfinapiclient.h"
#define STR2(x) #x
#define STR(x) STR2(x)
JellyfinApiClient::JellyfinApiClient(QObject *parent)
: QObject(parent) {
m_deviceName = QHostInfo::localHostName();
m_deviceId = QUuid::createUuid().toString();
m_credManager = CredentialsManager::getInstance(this);
}
////////////////////////////////////////////////////////////////////////////////////////////////////
// BASE HTTP METHODS //
////////////////////////////////////////////////////////////////////////////////////////////////////
void JellyfinApiClient::addBaseRequestHeaders(QNetworkRequest &request, const QString &path, const QUrlQuery &params) {
QString authentication = "MediaBrowser ";
authentication += "Client=\"Sailfin\"";
authentication += ", Device=\"" + m_deviceName + "\"";
authentication += ", DeviceId=\"" + m_deviceId + "\"";
authentication += ", Version=\"" + QString(STR(SAILFIN_VERSION)) + "\"";
if (m_authenticated) {
authentication += ", token=\"" + m_token + "\"";
}
request.setRawHeader("X-Emby-Authorization", authentication.toUtf8());
request.setRawHeader("Accept", "application/json");
request.setHeader(QNetworkRequest::UserAgentHeader, QString("Sailfin/%1").arg(STR(SAILFIN_VERSION)));
request.setUrl(this->m_baseUrl + path + "?" + params.toString());
qDebug() << "REQUEST TO: " << request.url();
}
QNetworkReply *JellyfinApiClient::get(const QString &path, const QUrlQuery &params) {
QNetworkRequest req;
addBaseRequestHeaders(req, path, params);
return m_naManager.get(req);
}
QNetworkReply *JellyfinApiClient::post(const QString &path, const QJsonDocument &data) {
QNetworkRequest req;
req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
addBaseRequestHeaders(req, path);
if (data.isEmpty())
return m_naManager.post(req, QByteArray());
else {
return m_naManager.post(req, data.toJson(QJsonDocument::Compact));
}
}
////////////////////////////////////////////////////////////////////////////////////////////////////
// Nice to have methods //
////////////////////////////////////////////////////////////////////////////////////////////////////
void JellyfinApiClient::initialize(){
QObject *ctx1 = new QObject(this);
connect(m_credManager, &CredentialsManager::serversListed, ctx1, [this, ctx1](const QStringList &servers) {
qDebug() << "Servers listed: " << servers;
if (servers.size() == 0) {
emit this->setupRequired();
return;
}
//FIXME: support multiple servers
QString server = servers[0];
this->m_baseUrl = server;
qDebug() << "Server: " << server;
QObject *ctx2 = new QObject(this);
connect(m_credManager, &CredentialsManager::usersListed, ctx2, [this, server, ctx2](const QStringList &users) {
if (users.size() == 0) {
emit this->setupRequired();
return;
}
//FIXME: support multiple users
QString user = users[0];
qDebug() << "User: " << user;
QObject *ctx3 = new QObject(this);
connect(m_credManager, &CredentialsManager::tokenRetrieved, ctx3, [this, ctx3]
(const QString &server, const QString &user, const QString &token) {
Q_UNUSED(server)
this->m_token = token;
this->setUserId(user);
this->setAuthenticated(true);
disconnect(ctx3);
}, Qt::UniqueConnection);
m_credManager->get(server, user);
delete ctx2;
}, Qt::UniqueConnection);
m_credManager->listUsers(server);
qDebug() << "Listing users";
delete ctx1;
}, Qt::UniqueConnection);
qDebug() << "Listing servers";
m_credManager->listServers();
}
void JellyfinApiClient::setupConnection() {
// First detect redirects:
// Note that this is done without calling JellyfinApiClient::get since that automatically includes the base_url,
// which is something we want to avoid here.
QNetworkReply *rep = m_naManager.get(QNetworkRequest(m_baseUrl));
connect(rep, &QNetworkReply::finished, this, [rep, this](){
int status = statusCode(rep);
qDebug() << status;
// Check if redirect
if (status >= 300 && status < 400) {
QString location = QString::fromUtf8(rep->rawHeader("location"));
qInfo() << "Redirect from " << this->m_baseUrl << " to " << location;
QUrl base = QUrl(m_baseUrl);
QString newUrl = base.resolved(QUrl(location)).toString();
// If the url wants to redirect us to their web interface, we have to chop the last part of.
if (newUrl.endsWith("/web/index.html")) {
newUrl.chop(QString("/web/index.html").size());
this->setBaseUrl(newUrl);
getBrandingConfiguration();
} else {
this->setBaseUrl(newUrl);
setupConnection();
}
} else {
getBrandingConfiguration();
}
rep->deleteLater();
});
connect(rep, static_cast<void (QNetworkReply::*)(QNetworkReply::NetworkError)>(&QNetworkReply::error),
this, [rep, this](QNetworkReply::NetworkError error) {
qDebug() << "Error from URL: " << rep->url();
emit this->networkError(error);
rep->deleteLater();
});
}
void JellyfinApiClient::getBrandingConfiguration() {
QNetworkReply *rep = get("/Branding/Configuration");
connect(rep, &QNetworkReply::finished, this, [rep, this]() {
qDebug() << "RESPONSE: " << statusCode(rep);
switch(statusCode(rep)) {
case 200:
QJsonDocument response = QJsonDocument::fromJson(rep->readAll());
if (response.isNull() || !response.isObject()) {
emit this->connectionFailed(ApiError::JSON_ERROR);
} else {
QJsonObject obj = response.object();
if (obj.contains("LoginDisclaimer")) {
qDebug() << "Login disclaimer: " << obj["LoginDisclaimer"];
emit this->connectionSuccess(obj["LoginDisclaimer"].toString());
} else {
emit this->connectionSuccess("");
}
}
break;
}
rep->deleteLater();
});
connect(rep, static_cast<void (QNetworkReply::*)(QNetworkReply::NetworkError)>(&QNetworkReply::error),
this, [rep, this](QNetworkReply::NetworkError error) {
emit this->networkError(error);
rep->deleteLater();
});
}
void JellyfinApiClient::authenticate(QString username, QString password, bool storeCredentials) {
QJsonObject requestData;
requestData["Username"] = username;
requestData["Pw"] = password;
QNetworkReply *rep = post("/Users/Authenticatebyname", QJsonDocument(requestData));
connect(rep, &QNetworkReply::finished, this, [rep, username, storeCredentials, this]() {
int status = rep->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
qDebug() << "Got reply with status code " << status;
if (status >= 200 && status < 300) {
QJsonObject authInfo = QJsonDocument::fromJson(rep->readAll()).object();
this->m_token = authInfo["AccessToken"].toString();
this->setAuthenticated(true);
this->setUserId(authInfo["User"].toObject()["Id"].toString());
if (storeCredentials) {
m_credManager->store(this->m_baseUrl, this->m_userId, this->m_token);
}
}
rep->deleteLater();
});
connect(rep, static_cast<void (QNetworkReply::*)(QNetworkReply::NetworkError)>(&QNetworkReply::error),
this, &JellyfinApiClient::defaultNetworkErrorHandler);
}
void JellyfinApiClient::fetchItem(const QString &id) {
QNetworkReply *rep = get("/Users/" + m_userId + "/Items/" + id);
connect(rep, &QNetworkReply::finished, this, [rep, id, this]() {
int status = statusCode(rep);
if (status >= 200 && status < 300) {
QJsonObject data = QJsonDocument::fromJson(rep->readAll()).object();
emit this->itemFetched(id, data);
}
rep->deleteLater();
});
}
void JellyfinApiClient::defaultNetworkErrorHandler(QNetworkReply::NetworkError error) {
QObject *signalSender = sender();
QNetworkReply *rep = dynamic_cast<QNetworkReply *>(signalSender);
if (rep != nullptr && statusCode(rep) == 401) {
emit this->authenticationError(ApiError::INVALID_PASSWORD);
} else {
emit this->networkError(error);
}
rep->deleteLater();
}
#undef STR
#undef STR2

136
src/jellyfinapiclient.h Normal file
View file

@ -0,0 +1,136 @@
#ifndef JELLYFIN_API_CLIENT
#define JELLYFIN_API_CLIENT
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonParseError>
#include <QJsonValue>
#include <QObject>
#include <QString>
#include <QtQml>
#include <QUuid>
#include <QNetworkReply>
#include <QUrlQuery>
#include "credentialmanager.h"
class JellyfinApiClient : public QObject {
Q_OBJECT
public:
explicit JellyfinApiClient(QObject *parent = nullptr);
Q_PROPERTY(QString baseUrl MEMBER m_baseUrl NOTIFY baseUrlChanged)
Q_PROPERTY(bool authenticated READ authenticated WRITE setAuthenticated NOTIFY authenticatedChanged)
Q_PROPERTY(QString userId READ userId NOTIFY userIdChanged)
/*QNetworkReply *handleRequest(QString path, QStringList sort, Pagination *pagination,
QVariantMap filters, QStringList fields, QStringList expand, QString id);*/
bool authenticated() const { return m_authenticated; }
void setBaseUrl(QString url) {
this->m_baseUrl = url;
if (this->m_baseUrl.endsWith("/")) {
this->m_baseUrl.chop(1);
}
emit this->baseUrlChanged(m_baseUrl);
}
QNetworkReply *get(const QString &path, const QUrlQuery &params = QUrlQuery());
QNetworkReply *post(const QString &path, const QJsonDocument &data = QJsonDocument());
void getPublicUsers();
enum ApiError {
JSON_ERROR,
UNEXPECTED_REPLY,
UNEXPECTED_STATUS,
INVALID_PASSWORD
};
QString &userId() { return m_userId; }
signals:
/*
* Emitted when the server requires authentication. Please authenticate your user via authenticate.
*/
void authenticationRequired();
void authenticationError(ApiError error);
void connectionFailed(ApiError error);
void connectionSuccess(QString loginMessage);
void networkError(QNetworkReply::NetworkError error);
void authenticatedChanged(bool authenticated);
void baseUrlChanged(const QString &baseUrl);
/**
* @brief Set-up is required. You'll need to manually set up the baseUrl-property, call setupConnection
* afterwards and finally call authenticate.
*/
void setupRequired();
void userIdChanged(QString userId);
void itemFetched(const QString &itemId, const QJsonObject &result);
public slots:
/**
* @brief Tries to access credentials and connect to a server. If nothing has been configured yet,
* emits setupRequired();
*/
void initialize();
/*
* Try to connect with the server. Tries to resolve redirects and retrieves information
* about the login procedure. Emits connectionSuccess on success, networkError or ConnectionFailed
* otherwise.
*/
void setupConnection();
void authenticate(QString username, QString password, bool storeCredentials = false);
void fetchItem(const QString &id);
protected slots:
void defaultNetworkErrorHandler(QNetworkReply::NetworkError error);
protected:
/**
* @brief Adds default headers to each request, like authentication headers etc.
* @param request The request to add headers to
* @param path The path to which the request is being made
*/
void addBaseRequestHeaders(QNetworkRequest &request, const QString &path, const QUrlQuery &params = QUrlQuery());
/**
* @brief getBrandingConfiguration Gets the login message and custom CSS (which we ignore)
*/
void getBrandingConfiguration();
private:
CredentialsManager * m_credManager;
QString m_token;
QString m_deviceName;
QString m_deviceId;
QString m_userId = "";
void setAuthenticated(bool authenticated) {
this->m_authenticated = authenticated;
emit authenticatedChanged(authenticated);
}
void setUserId(QString userId) {
this->m_userId = userId;
emit userIdChanged(userId);
}
bool m_authenticated = false;
QString m_baseUrl;
QNetworkAccessManager m_naManager;
static inline int statusCode(QNetworkReply *rep) {
return rep->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
}
};
#endif // JELLYFIN_API_CLIENT

115
src/jellyfinapimodel.cpp Normal file
View file

@ -0,0 +1,115 @@
#include "jellyfinapimodel.h"
namespace Jellyfin {
ApiModel::ApiModel(QString path, QString subfield, QObject *parent)
: QAbstractListModel (parent),
m_path(path),
m_subfield(subfield) {
}
void ApiModel::reload() {
this->setStatus(Loading);
if (m_apiClient == nullptr) {
qWarning() << "Please set the apiClient property before (re)loading";
return;
}
if (m_path.contains(":user")) {
qDebug() << "Path contains :user, replacing with" << m_apiClient->userId();
m_path = m_path.replace(":user", m_apiClient->userId());
}
QUrlQuery query;
if (m_limit >= 0) {
query.addQueryItem("Limit", QString::number(m_limit));
}
if (!m_parentId.isEmpty()) {
query.addQueryItem("ParentId", m_parentId);
}
if (m_sortBy.empty()) {
query.addQueryItem("SortBy", enumListToString(m_sortBy));
}
QNetworkReply *rep = m_apiClient->get(m_path, query);
connect(rep, &QNetworkReply::finished, this, [this, rep]() {
QJsonDocument doc = QJsonDocument::fromJson(rep->readAll());
if (m_subfield.trimmed().isEmpty()) {
if (!doc.isArray()) {
qWarning() << "Object is not an array!";
this->setStatus(Error);
return;
}
this->m_array = doc.array();
} else {
if (!doc.isObject()) {
qWarning() << "Object is not an object!";
this->setStatus(Error);
return;
}
QJsonObject obj = doc.object();
if (!obj.contains(m_subfield)) {
qWarning() << "Object doesn't contain required subfield!";
this->setStatus(Error);
return;
}
if (!obj[m_subfield].isArray()) {
qWarning() << "Object's subfield is not an array!";
this->setStatus(Error);
return;
}
this->m_array = obj[m_subfield].toArray();
}
generateFields();
this->setStatus(Ready);
rep->deleteLater();
});
}
void ApiModel::generateFields() {
if (m_array.size() == 0) return;
this->beginResetModel();
m_roles.clear();
int i = Qt::UserRole + 1;
if (!m_array[0].isObject()) {
qWarning() << "Iterator is not an object?";
return;
}
// Walks over the keys in the first record and adds them to the rolenames.
// This assumes the back-end has the same keys for every record. I could technically
// go over all records to be really sure, but no-one got time for a O(n²) algorithm, so
// this heuristic hopefully suffices.
QJsonObject ob = m_array[0].toObject();
for (auto jt = ob.begin(); jt != ob.end(); jt++) {
QString keyName = jt.key();
keyName[0] = keyName[0].toLower();
QByteArray keyArr = keyName.toUtf8();
if (!m_roles.values().contains(keyArr)) {
m_roles.insert(i++, keyArr);
qDebug() << m_path << " adding " << keyName << " as " << ( i - 1);
}
}
this->endResetModel();
}
QVariant ApiModel::data(const QModelIndex &index, int role) const {
// Ignore roles we don't know
if (role <= Qt::UserRole || role >= Qt::UserRole + m_roles.size()) return QVariant();
// Ignore invalid indices.
if (!index.isValid()) return QVariant();
QJsonObject obj = m_array.at(index.row()).toObject();
QString key = m_roles[role];
key[0] = key[0].toUpper();
if (obj.contains(key)) {
return obj[key].toVariant();
}
return QVariant();
}
void registerModels(const char *URI) {
qmlRegisterUncreatableType<ApiModel>(URI, 1, 0, "ApiModel", "Is enum and base class");
qmlRegisterUncreatableType<SortOrder>(URI, 1, 0, "SortOrder", "Is enum");
qmlRegisterType<PublicUserModel>(URI, 1, 0, "PublicUserModel");
qmlRegisterType<UserViewModel>(URI, 1, 0, "UserViewModel");
qmlRegisterType<UserItemModel>(URI, 1, 0, "UserItemModel");
}
}

200
src/jellyfinapimodel.h Normal file
View file

@ -0,0 +1,200 @@
#ifndef JELLYFIN_API_MODEL
#define JELLYFIN_API_MODEL
#include <QAbstractListModel>
#include <QFlags>
#include <QMetaEnum>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QVariant>
#include "jellyfinapiclient.h"
namespace Jellyfin {
class SortOrder {
Q_GADGET
public:
enum SortBy {
Album,
AlbumArtist,
Artist,
Budget,
CommunityRating,
CriticRating,
DateCreated,
DatePlayed,
PlayCount,
PremiereDate,
ProductionYear,
SortName,
Random,
Revenue,
Runtime
};
Q_ENUM(SortBy)
};
/**
* @brief Abstract model for displaying a REST JSON collection. Role names will be based on the fields encountered in the
* first record.
*
* To create a new model, extend this class and create an QObject-parent constructor.
* Call the right super constructor with the right values, depending which path should be queried and
* how the result should be interpreted.
*
* Register the model in QML and create an instance. Don't forget to set the apiClient attribute or else
* the model you've created will be useless!
*
* Rolenames are based on the fields in the first object within the array of results, with the first letter
* lowercased, to accomodate for QML style guidelines. (This ain't C# here).
*
* If a call to /cats/new results in
* @code{.json}
* [
* {"Name": "meow", "Id": 432},
* {"Name": "miew", "Id": 323}
* ]
* @endcode
* The model will have roleNames for "name" and "id".
*
*/
class ApiModel : public QAbstractListModel {
Q_OBJECT
public:
enum ModelStatus {
Uninitialised,
Loading,
Ready,
Error
};
Q_ENUM(ModelStatus)
enum MediaType {
MediaUnspecified,
Series
};
Q_DECLARE_FLAGS(MediaTypes, MediaType)
Q_FLAG(MediaTypes)
/**
* @brief Creates a new basemodel
* @param path The path (relative to the baseUrl of JellyfinApiClient) to make the call to.
* @param subfield Leave empty if the root of the result is the array with results. Otherwise, set to the key name in the
* root object which contains the data.
* @param parent Parent (Standard QObject stuff)
*
* If the response looks something like this:
* @code{.json}
* [{...}, {...}, {...}]
* @endcode
* subfield should be left empty
*
* If the response looks something like this:
* @code{.json}
* {
* "offset": 0,
* "count": 20,
* "data": [{...}, {...}, {...}, ..., {...}]
* }
* @endcode
* Subfield should be set to "data" in this example.
*/
explicit ApiModel(QString path, QString subfield, QObject *parent = nullptr);
Q_PROPERTY(JellyfinApiClient *apiClient MEMBER m_apiClient)
Q_PROPERTY(ModelStatus status READ status NOTIFY statusChanged)
Q_PROPERTY(int limit MEMBER m_limit NOTIFY limitChanged)
Q_PROPERTY(QString parentId MEMBER m_parentId NOTIFY parentIdChanged)
Q_PROPERTY(QList<SortOrder::SortBy> sortBy MEMBER m_sortBy NOTIFY sortByChanged)
//Q_PROPERTY(MediaTypes includeTypes MEMBER m_includeTypes NOTIFY includeTypesChanged)
int rowCount(const QModelIndex &index) const override {
if (!index.isValid()) return m_array.size();
return 0;
}
QHash<int, QByteArray> roleNames() const override { return m_roles; }
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
ModelStatus status() const { return m_status; }
template<typename QEnum>
QString enumToString (const QEnum anEnum) { return QVariant::fromValue(anEnum).toString(); }
template<typename QEnum>
QString enumListToString (const QList<QEnum> enumList) {
QString result;
for (QEnum e : enumList) {
result += QVariant::fromValue(e).toString() + ",";
}
return result;
}
signals:
void statusChanged(ModelStatus newStatus);
void limitChanged(int newLimit);
void parentIdChanged(QString newParentId);
void sortByChanged(SortOrder::SortBy newSortOrder);
void includeTypesChanged(MediaTypes newTypes);
public slots:
/**
* @brief (Re)loads the data into this model. This might make a network request.
*/
void reload();
protected:
JellyfinApiClient *m_apiClient = nullptr;
ModelStatus m_status = Uninitialised;
QString m_path;
QString m_subfield;
QJsonArray m_array;
// Query properties
int m_limit = -1;
QString m_parentId;
QList<SortOrder::SortBy> m_sortBy = {};
MediaTypes m_includeTypes = MediaUnspecified;
QHash<int, QByteArray> m_roles;
//QHash<QByteArray, int> m_reverseRoles;
void setStatus(ModelStatus newStatus) {
this->m_status = newStatus;
emit this->statusChanged(newStatus);
}
private:
/**
* @brief Generates roleNames based on the first record in m_array.
*/
void generateFields();
QString sortByToString(SortOrder::SortBy sortBy);
QString mediaTypeToString(MediaType mediaType);
};
/**
* @brief List of the public users on the server.
*/
class PublicUserModel : public ApiModel {
public:
explicit PublicUserModel (QObject *parent = nullptr)
: ApiModel ("/users/public", "", parent) { }
};
class UserViewModel : public ApiModel {
public:
explicit UserViewModel (QObject *parent = nullptr)
: ApiModel ("/Users/:user/Views", "Items", parent) {}
};
class UserItemModel : public ApiModel {
public:
explicit UserItemModel (QObject *parent = nullptr)
: ApiModel ("/Users/:user/Items", "Items", parent) {}
};
void registerModels(const char *URI);
Q_DECLARE_OPERATORS_FOR_FLAGS(ApiModel::MediaTypes)
}
#endif //JELLYFIN_API_MODEL

View file

@ -0,0 +1,73 @@
#include "serverdiscoverymodel.h"
ServerDiscoveryModel::ServerDiscoveryModel(QObject *parent)
: QAbstractListModel (parent) {
connect(&m_socket, &QUdpSocket::readyRead, this, &ServerDiscoveryModel::on_datagramsAvailable);
m_socket.bind(BROADCAST_PORT);
}
QVariant ServerDiscoveryModel::data(const QModelIndex &index, int role) const {
if (index.row() < 0 || index.row() >= rowCount()) return QVariant();
size_t row = static_cast<size_t>(index.row());
switch(role) {
case ROLE_ADDRESS:
return m_discoveredServers[row].address;
case ROLE_ID:
return m_discoveredServers[row].id;
case ROLE_NAME:
return m_discoveredServers[row].name;
default:
return QVariant();
}
}
void ServerDiscoveryModel::refresh() {
this->beginResetModel();
this->m_discoveredServers.clear();
this->endResetModel();
m_socket.writeDatagram(MAGIC_PACKET, QHostAddress::Broadcast, BROADCAST_PORT);
}
void ServerDiscoveryModel::on_datagramsAvailable() {
int beginIndex = static_cast<int>(m_discoveredServers.size());
QByteArray datagram;
QJsonDocument jsonDocument;
QJsonParseError jsonParseError;
QHostAddress replyAddress;
std::vector<ServerDiscovery> discoveredServers;
while (m_socket.hasPendingDatagrams()) {
datagram.resize(static_cast<int>(m_socket.pendingDatagramSize()));
m_socket.readDatagram(datagram.data(), datagram.size(), &replyAddress);
jsonDocument = QJsonDocument::fromJson(datagram, &jsonParseError);
// Check if parsing failed
if (jsonDocument.isNull()) {
qDebug() << "Invalid response from " << replyAddress.toString() << ": " << jsonParseError.errorString();
continue;
}
if (jsonDocument.isObject()) {
QJsonObject rootObject = jsonDocument.object();
if (rootObject.contains("Name") && rootObject.contains("Address") && rootObject.contains("Id")) {
// We (assume) we have a correct response! Add it to the back of our temporary vector with discovered servers
discoveredServers.push_back(ServerDiscovery {
rootObject["Name"].toString(),
rootObject["Address"].toString(),
rootObject["Id"].toString()
});
} else {
qDebug() << "Invalid response from " << replyAddress.toString() << ": does not contain Name, Address, or Id field";
}
} else {
qDebug() << "Invalid response from " << replyAddress.toString() << ": root is not an object";
}
}
beginInsertRows(QModelIndex(), beginIndex, beginIndex + static_cast<int>(discoveredServers.size()) - 1);
m_discoveredServers.insert(m_discoveredServers.end(), discoveredServers.begin(), discoveredServers.end());
endInsertRows();
};

View file

@ -0,0 +1,63 @@
#ifndef SERVER_DISCOVERY_MODEL_H
#define SERVER_DISCOVERY_MODEL_H
#include <vector>
#include <QAbstractListModel>
#include <QHash>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonParseError>
#include <QHostAddress>
#include <QUdpSocket>
struct ServerDiscovery {
QString name;
QString address;
QString id;
};
/**
* @brief Discovers nearby Jellyfin servers and puts them in this list.
*/
class ServerDiscoveryModel : public QAbstractListModel {
Q_OBJECT
public:
enum Roles {
ROLE_NAME = Qt::UserRole + 1,
ROLE_ADDRESS,
ROLE_ID
};
explicit ServerDiscoveryModel(QObject *parent = nullptr);
QHash<int, QByteArray> roleNames() const override {
return {
{ROLE_NAME, "name"},
{ROLE_ADDRESS, "address"},
{ROLE_ID, "id"}
};
}
int rowCount(const QModelIndex &parent = QModelIndex()) const override {
if (parent.isValid()) return 0;
return static_cast<int>(m_discoveredServers.size());
}
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
public slots:
/**
* @brief Refreshes the model and searches for new servers
*/
void refresh();
private slots:
void on_datagramsAvailable();
private:
const QByteArray MAGIC_PACKET = "who is JellyfinServer?";
const quint16 BROADCAST_PORT = 7359;
QUdpSocket m_socket;
std::vector<ServerDiscovery> m_discoveredServers;
};
#endif //SERVER_DISCOVERY_MODEL_H