mirror of
https://github.com/HenkKalkwater/harbour-sailfin.git
synced 2025-09-04 01:42:44 +00:00
Deserialized a list! Restructured project!
I finally got deserializing lists working. Exposing them to QML was not a trivial task either. Note that I didn't do it the clean way. Nested lists are not supported. But it works! Because I got so frustarted at one point trying to implement things the right way, I restructured the project to seperate the Sailfish code from the Qt code and created a new, empty desktop project. The Qt code has been transformed into a happy little library, to which the Sailfish OS application links. Note that QMake doesn't seem to strip the library for some reason.
This commit is contained in:
parent
4e3395c4e5
commit
1e80ceb697
77 changed files with 507 additions and 213 deletions
32
core/core.pro
Normal file
32
core/core.pro
Normal file
|
@ -0,0 +1,32 @@
|
|||
TEMPLATE = lib
|
||||
QT += qml multimedia network websockets
|
||||
|
||||
include(defines.pri)
|
||||
include(../harbour-sailfin.pri)
|
||||
|
||||
SOURCES += \
|
||||
src/credentialmanager.cpp \
|
||||
src/jellyfin.cpp \
|
||||
src/jellyfinapiclient.cpp \
|
||||
src/jellyfinapimodel.cpp \
|
||||
src/jellyfindeviceprofile.cpp \
|
||||
src/jellyfinitem.cpp \
|
||||
src/jellyfinplaybackmanager.cpp \
|
||||
src/jellyfinwebsocket.cpp \
|
||||
src/serverdiscoverymodel.cpp
|
||||
|
||||
HEADERS += \
|
||||
include/credentialmanager.h \
|
||||
include/jellyfin.h \
|
||||
include/jellyfinapiclient.h \
|
||||
include/jellyfinapimodel.h \
|
||||
include/jellyfindeviceprofile.h \
|
||||
include/jellyfinitem.h \
|
||||
include/jellyfinplaybackmanager.h \
|
||||
include/jellyfinwebsocket.h \
|
||||
include/serverdiscoverymodel.h
|
||||
|
||||
VERSION = $$SAILFIN_VERSION
|
||||
|
||||
TARGET = jellyfin-qt
|
||||
DESTDIR = lib
|
2
core/defines.pri
Normal file
2
core/defines.pri
Normal file
|
@ -0,0 +1,2 @@
|
|||
message(Including $$_FILE_ from $$IN_PWD)
|
||||
INCLUDEPATH += $$IN_PWD/include
|
130
core/include/credentialmanager.h
Normal file
130
core/include/credentialmanager.h
Normal file
|
@ -0,0 +1,130 @@
|
|||
/*
|
||||
Sailfin: a Jellyfin client written using Qt
|
||||
Copyright (C) 2020 Chris Josten
|
||||
|
||||
This library is free software; you can redistribute it and/or
|
||||
modify it under the terms of the GNU Lesser General Public
|
||||
License as published by the Free Software Foundation; either
|
||||
version 2.1 of the License, or (at your option) any later version.
|
||||
|
||||
This library is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
Lesser General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Lesser General Public
|
||||
License along with this library; if not, write to the Free Software
|
||||
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
*/
|
||||
|
||||
#ifndef CREDENTIALS_MANAGER_H
|
||||
#define CREDENTIALS_MANAGER_H
|
||||
|
||||
#include <QDebug>
|
||||
#include <QHash>
|
||||
#include <QObject>
|
||||
#include <QSettings>
|
||||
#include <QString>
|
||||
|
||||
/**
|
||||
* @brief The CredentialsManager class stores credentials for users.
|
||||
*
|
||||
* You can get an instance using ::instance(), which may depend on the platform being
|
||||
* used. Since the implementation may be asynchronous, the methods won't return anything,
|
||||
* they emit a corresponding signal instead.
|
||||
*/
|
||||
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
|
||||
*
|
||||
* This method is always guaranteed to return an instance.
|
||||
* @return An implementation of this interface (may vary acrros platform).
|
||||
*/
|
||||
static CredentialsManager *newInstance(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
|
16
core/include/jellyfin.h
Normal file
16
core/include/jellyfin.h
Normal file
|
@ -0,0 +1,16 @@
|
|||
#ifndef JELLYFIN_H
|
||||
#define JELLYFIN_H
|
||||
|
||||
#include <QtQml>
|
||||
|
||||
#include "jellyfinapiclient.h"
|
||||
#include "jellyfinapimodel.h"
|
||||
#include "jellyfinitem.h"
|
||||
#include "serverdiscoverymodel.h"
|
||||
#include "jellyfinplaybackmanager.h"
|
||||
|
||||
namespace Jellyfin {
|
||||
void registerTypes();
|
||||
}
|
||||
|
||||
#endif // JELLYFIN_H
|
255
core/include/jellyfinapiclient.h
Normal file
255
core/include/jellyfinapiclient.h
Normal file
|
@ -0,0 +1,255 @@
|
|||
/*
|
||||
Sailfin: a Jellyfin client written using Qt
|
||||
Copyright (C) 2020 Chris Josten
|
||||
|
||||
This library is free software; you can redistribute it and/or
|
||||
modify it under the terms of the GNU Lesser General Public
|
||||
License as published by the Free Software Foundation; either
|
||||
version 2.1 of the License, or (at your option) any later version.
|
||||
|
||||
This library is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
Lesser General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Lesser General Public
|
||||
License along with this library; if not, write to the Free Software
|
||||
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
*/
|
||||
|
||||
#ifndef JELLYFIN_API_CLIENT
|
||||
#define JELLYFIN_API_CLIENT
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonParseError>
|
||||
#include <QJsonValue>
|
||||
|
||||
#include <QHostInfo>
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QSysInfo>
|
||||
#include <QtQml>
|
||||
#include <QUuid>
|
||||
|
||||
#include <QNetworkReply>
|
||||
#include <QUrlQuery>
|
||||
|
||||
#include "credentialmanager.h"
|
||||
#include "jellyfindeviceprofile.h"
|
||||
#include "jellyfinwebsocket.h"
|
||||
|
||||
namespace Jellyfin {
|
||||
class MediaSource;
|
||||
class WebSocket;
|
||||
/**
|
||||
* @brief An Api client for Jellyfin. Handles requests and authentication.
|
||||
*
|
||||
* This class should also be given to certain models and other sources, so they are able to make
|
||||
* requests to the correct server.
|
||||
*
|
||||
* General usage is as follows:
|
||||
* 1. (Optional) Call restoreSavedSession(). This will try to load previously saved credentials and connect to the server.
|
||||
* If all succeeds, the property authenticated should be set to true and its signal should be emitted. All is done.
|
||||
* If it fails, setupRequired will be emitted. Continue following these steps.
|
||||
* 2. If opting in to manually manage the session or restoreSavedSession() failed, you'll need to set the property
|
||||
* baseUrl to the root of the Jellyfin server, e.g. "https://jellyfin.example.com:8098", so not the url to the
|
||||
* web interface! Nearby servers can be discovered using Jellyfin::ServerDiscoveryModel.
|
||||
* 3. Call ::setupConnection(). First of all, the client will try to resolve any redirects and will update
|
||||
* the baseUrl property if following redirects. Then it will emit connectionSuccess(QString). The QString from
|
||||
* the signal contains a user-oriented login message configured by the user that should be displayed in the URL
|
||||
* somewhere.
|
||||
* 4. After ::connected is emitted, call ::authenticate(QString, QString, bool). with the username and password.
|
||||
* The last boolean argument is used if you want to have the ApiClient store your credentials, so that they
|
||||
* later can be used with restoreSavedSession().
|
||||
* 5. If the authenticated property is set to true, you are now authenticated! If loginError() is emitted, you aren't and
|
||||
* you should go back to step 4.
|
||||
*
|
||||
* These steps might change. I'm considering decoupling CredentialsManager from this class to clean some code up.
|
||||
*/
|
||||
class ApiClient : public QObject {
|
||||
friend class WebSocket;
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit ApiClient(QObject *parent = nullptr);
|
||||
Q_PROPERTY(QString baseUrl MEMBER m_baseUrl READ baseUrl NOTIFY baseUrlChanged)
|
||||
Q_PROPERTY(bool authenticated READ authenticated WRITE setAuthenticated NOTIFY authenticatedChanged)
|
||||
Q_PROPERTY(QString userId READ userId NOTIFY userIdChanged)
|
||||
Q_PROPERTY(QString version READ version)
|
||||
|
||||
/*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 ¶ms = QUrlQuery());
|
||||
QNetworkReply *post(const QString &path, const QJsonDocument &data = QJsonDocument(), const QUrlQuery ¶ms = QUrlQuery());
|
||||
void getPublicUsers();
|
||||
|
||||
enum ApiError {
|
||||
JSON_ERROR,
|
||||
UNEXPECTED_REPLY,
|
||||
UNEXPECTED_STATUS,
|
||||
INVALID_PASSWORD
|
||||
};
|
||||
|
||||
QString &baseUrl() { return this->m_baseUrl; }
|
||||
QString &userId() { return m_userId; }
|
||||
QJsonObject &deviceProfile() { return m_deviceProfile; }
|
||||
QJsonObject &playbackDeviceProfile() { return m_playbackDeviceProfile; }
|
||||
QString version() const;
|
||||
|
||||
/**
|
||||
* @brief Sets the error handler of a reply to this classes default error handler
|
||||
* @param rep The reply to set the error handler on.
|
||||
*
|
||||
* Motivation for this helper is because I forget the correct signature each time, with all the
|
||||
* funky casts.
|
||||
*/
|
||||
void setDefaultErrorHandler(QNetworkReply *rep) {
|
||||
connect(rep, static_cast<void (QNetworkReply::*)(QNetworkReply::NetworkError)>(&QNetworkReply::error),
|
||||
this, &ApiClient::defaultNetworkErrorHandler);
|
||||
}
|
||||
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);
|
||||
void itemFetchFailed(const QString &itemId, const QNetworkReply::NetworkError error);
|
||||
|
||||
public slots:
|
||||
/**
|
||||
* @brief Tries to access credentials and connect to a server. If nothing has been configured yet,
|
||||
* emits setupRequired();
|
||||
*/
|
||||
void restoreSavedSession();
|
||||
/*
|
||||
* 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);
|
||||
|
||||
/**
|
||||
* @brief Logs the user out and clears the session.
|
||||
*/
|
||||
void deleteSession();
|
||||
|
||||
void fetchItem(const QString &id);
|
||||
|
||||
/**
|
||||
* @brief Shares the capabilities of this device to the server.
|
||||
*/
|
||||
void postCapabilities();
|
||||
|
||||
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 ¶ms = QUrlQuery());
|
||||
|
||||
/**
|
||||
* @brief Adds the authorization to the header
|
||||
* @param The request to add the header to
|
||||
*/
|
||||
void addTokenHeader(QNetworkRequest &request);
|
||||
|
||||
/**
|
||||
* @brief getBrandingConfiguration Gets the login message and custom CSS (which we ignore)
|
||||
*/
|
||||
void getBrandingConfiguration();
|
||||
|
||||
/**
|
||||
* @brief Generates a profile, containing the name of the application, manufacturer and most importantly,
|
||||
* which media types this device supports.
|
||||
*
|
||||
* The actual detection of supported media types is done within jellyfindeviceprofile.cpp, since the code
|
||||
* is a big mess and should be safely contained in it's own file.
|
||||
*/
|
||||
void generateDeviceProfile();
|
||||
|
||||
QString &token() { return m_token; }
|
||||
|
||||
private:
|
||||
QNetworkAccessManager m_naManager;
|
||||
/*
|
||||
* State information
|
||||
*/
|
||||
WebSocket *m_webSocket;
|
||||
CredentialsManager * m_credManager;
|
||||
QString m_token;
|
||||
QString m_deviceName;
|
||||
QString m_deviceId;
|
||||
QString m_userId = "";
|
||||
QJsonObject m_deviceProfile;
|
||||
QJsonObject m_playbackDeviceProfile;
|
||||
|
||||
bool m_authenticated = false;
|
||||
/**
|
||||
* @brief The base url of the request.
|
||||
*/
|
||||
QString m_baseUrl;
|
||||
|
||||
/*
|
||||
* Setters
|
||||
*/
|
||||
|
||||
void setAuthenticated(bool authenticated);
|
||||
|
||||
void setUserId(QString userId) {
|
||||
this->m_userId = userId;
|
||||
emit userIdChanged(userId);
|
||||
}
|
||||
|
||||
/*
|
||||
* Utilities
|
||||
*/
|
||||
|
||||
/**
|
||||
* @brief Returns the statusCode of a QNetworkReply
|
||||
* @param The reply to obtain the statusCode of
|
||||
* @return The statuscode of the reply
|
||||
*
|
||||
* Seriously, Qt, why is your method to obtain the status code of a request so horrendous?
|
||||
*/
|
||||
static inline int statusCode(QNetworkReply *rep) {
|
||||
return rep->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
||||
}
|
||||
|
||||
};
|
||||
} // NS Jellyfin
|
||||
|
||||
#endif // JELLYFIN_API_CLIENT
|
296
core/include/jellyfinapimodel.h
Normal file
296
core/include/jellyfinapimodel.h
Normal file
|
@ -0,0 +1,296 @@
|
|||
/*
|
||||
Sailfin: a Jellyfin client written using Qt
|
||||
Copyright (C) 2020 Chris Josten
|
||||
|
||||
This library is free software; you can redistribute it and/or
|
||||
modify it under the terms of the GNU Lesser General Public
|
||||
License as published by the Free Software Foundation; either
|
||||
version 2.1 of the License, or (at your option) any later version.
|
||||
|
||||
This library is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
Lesser General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Lesser General Public
|
||||
License along with this library; if not, write to the Free Software
|
||||
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
*/
|
||||
|
||||
#ifndef JELLYFIN_API_MODEL
|
||||
#define JELLYFIN_API_MODEL
|
||||
|
||||
#include <QAbstractListModel>
|
||||
#include <QFlags>
|
||||
#include <QMetaEnum>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QtQml>
|
||||
#include <QVariant>
|
||||
|
||||
#include "jellyfinapiclient.h"
|
||||
|
||||
namespace Jellyfin {
|
||||
class SortOptions : public QObject{
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit SortOptions (QObject *parent = nullptr) : QObject(parent) {}
|
||||
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,
|
||||
LoadingMore
|
||||
};
|
||||
Q_ENUM(ModelStatus)
|
||||
|
||||
enum SortOrder {
|
||||
Unspecified,
|
||||
Ascending,
|
||||
Descending
|
||||
};
|
||||
Q_ENUM(SortOrder)
|
||||
|
||||
/**
|
||||
* @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
|
||||
*
|
||||
* or
|
||||
* @code{.json}
|
||||
* {...}
|
||||
* @endcode
|
||||
* responseHasRecords should be false
|
||||
*
|
||||
* If the response looks something like this:
|
||||
* @code{.json}
|
||||
* {
|
||||
* "Offset": 0,
|
||||
* "Count": 20,
|
||||
* "Items": [{...}, {...}, {...}, ..., {...}]
|
||||
* }
|
||||
* @endcode
|
||||
* responseHasRecords should be true
|
||||
*/
|
||||
explicit ApiModel(QString path, bool responseHasRecords, bool passUserId = false, QObject *parent = nullptr);
|
||||
Q_PROPERTY(ApiClient *apiClient MEMBER m_apiClient)
|
||||
Q_PROPERTY(ModelStatus status READ status NOTIFY statusChanged)
|
||||
|
||||
// Query properties
|
||||
Q_PROPERTY(int limit MEMBER m_limit NOTIFY limitChanged)
|
||||
Q_PROPERTY(QString parentId MEMBER m_parentId NOTIFY parentIdChanged)
|
||||
Q_PROPERTY(QList<QString> sortBy MEMBER m_sortBy NOTIFY sortByChanged)
|
||||
Q_PROPERTY(QList<QString> fields MEMBER m_fields NOTIFY fieldsChanged)
|
||||
Q_PROPERTY(QString seasonId MEMBER m_seasonId NOTIFY seasonIdChanged)
|
||||
Q_PROPERTY(QList<QString> imageTypes MEMBER m_imageTypes NOTIFY imageTypesChanged)
|
||||
Q_PROPERTY(bool recursive MEMBER m_recursive)
|
||||
Q_PROPERTY(SortOrder sortOrder MEMBER m_sortOrder NOTIFY sortOrderChanged)
|
||||
|
||||
// Path properties
|
||||
Q_PROPERTY(QString show MEMBER m_show NOTIFY showChanged)
|
||||
|
||||
// Standard QAbstractItemModel overrides
|
||||
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;
|
||||
bool canFetchMore(const QModelIndex &parent) const override;
|
||||
void fetchMore(const QModelIndex &parent) override;
|
||||
|
||||
ModelStatus status() const { return m_status; }
|
||||
|
||||
// Helper methods
|
||||
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(QList<QString> newSortOrder);
|
||||
void sortOrderChanged(SortOrder newSortOrder);
|
||||
void showChanged(QString newShow);
|
||||
void seasonIdChanged(QString newSeasonId);
|
||||
void fieldsChanged(QList<QString> newFields);
|
||||
void imageTypesChanged(QList<QString> newImageTypes);
|
||||
|
||||
public slots:
|
||||
/**
|
||||
* @brief (Re)loads the data into this model. This might make a network request.
|
||||
*/
|
||||
void reload();
|
||||
protected:
|
||||
|
||||
enum LoadType {
|
||||
RELOAD,
|
||||
LOAD_MORE
|
||||
};
|
||||
|
||||
void load(LoadType loadType);
|
||||
/**
|
||||
* @brief Adds parameters to the query
|
||||
* @param query The query to add parameters to
|
||||
*
|
||||
* This method is intended to be overrided by subclasses. It gets called
|
||||
* before a request is made to the server and can be used to enable
|
||||
* query types specific for a certain model to be available.
|
||||
*/
|
||||
virtual void addQueryParameters(QUrlQuery &query);
|
||||
ApiClient *m_apiClient = nullptr;
|
||||
ModelStatus m_status = Uninitialised;
|
||||
|
||||
QString m_path;
|
||||
QJsonArray m_array;
|
||||
bool m_hasRecordResponse;
|
||||
|
||||
// Path properties
|
||||
QString m_show;
|
||||
|
||||
// Query/record controlling properties
|
||||
int m_limit = -1;
|
||||
int m_startIndex = 0;
|
||||
int m_totalRecordCount = 0;
|
||||
const int DEFAULT_LIMIT = 100;
|
||||
|
||||
// Query properties
|
||||
bool m_addUserId = false;
|
||||
QString m_parentId;
|
||||
QString m_seasonId;
|
||||
QList<QString> m_fields;
|
||||
QList<QString> m_imageTypes;
|
||||
QList<QString> m_sortBy = {};
|
||||
SortOrder m_sortOrder = Unspecified;
|
||||
bool m_recursive = false;
|
||||
|
||||
QHash<int, QByteArray> m_roles;
|
||||
|
||||
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(SortOptions::SortBy sortBy);
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief List of the public users on the server.
|
||||
*/
|
||||
class PublicUserModel : public ApiModel {
|
||||
public:
|
||||
explicit PublicUserModel (QObject *parent = nullptr)
|
||||
: ApiModel ("/users/public", false, false, parent) { }
|
||||
};
|
||||
|
||||
class UserViewModel : public ApiModel {
|
||||
public:
|
||||
explicit UserViewModel (QObject *parent = nullptr)
|
||||
: ApiModel ("/Users/{{user}}/Views", true, false, parent) {}
|
||||
};
|
||||
|
||||
class UserItemModel : public ApiModel {
|
||||
public:
|
||||
explicit UserItemModel (QObject *parent = nullptr)
|
||||
: ApiModel ("/Users/{{user}}/Items", true, false, parent) {}
|
||||
};
|
||||
|
||||
class UserItemResumeModel : public ApiModel {
|
||||
public:
|
||||
explicit UserItemResumeModel (QObject *parent = nullptr)
|
||||
: ApiModel ("/Users/{{user}}/Items/Resume", true, false, parent) {}
|
||||
};
|
||||
|
||||
class UserItemLatestModel : public ApiModel {
|
||||
public:
|
||||
explicit UserItemLatestModel (QObject *parent = nullptr)
|
||||
: ApiModel ("/Users/{{user}}/Items/Latest", false, false, parent) {}
|
||||
};
|
||||
|
||||
class ShowSeasonsModel : public ApiModel {
|
||||
public:
|
||||
explicit ShowSeasonsModel (QObject *parent = nullptr)
|
||||
: ApiModel ("/Shows/{{show}}/Seasons", true, true, parent) {}
|
||||
};
|
||||
|
||||
class ShowEpisodesModel : public ApiModel {
|
||||
public:
|
||||
explicit ShowEpisodesModel (QObject *parent = nullptr)
|
||||
: ApiModel ("/Shows/{{show}}/Episodes", true, true, parent) {}
|
||||
};
|
||||
|
||||
|
||||
void registerModels(const char *URI);
|
||||
|
||||
}
|
||||
#endif //JELLYFIN_API_MODEL
|
52
core/include/jellyfindeviceprofile.h
Normal file
52
core/include/jellyfindeviceprofile.h
Normal file
|
@ -0,0 +1,52 @@
|
|||
/*
|
||||
Sailfin: a Jellyfin client written using Qt
|
||||
Copyright (C) 2020 Chris Josten
|
||||
|
||||
This library is free software; you can redistribute it and/or
|
||||
modify it under the terms of the GNU Lesser General Public
|
||||
License as published by the Free Software Foundation; either
|
||||
version 2.1 of the License, or (at your option) any later version.
|
||||
|
||||
This library is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
Lesser General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Lesser General Public
|
||||
License along with this library; if not, write to the Free Software
|
||||
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
*/
|
||||
|
||||
#ifndef JELLYFIN_DEVICE_PROFILE_H
|
||||
#define JELLYFIN_DEVICE_PROFILE_H
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonValue>
|
||||
#include <QList>
|
||||
#include <QMap>
|
||||
#include <QString>
|
||||
#include <QSysInfo>
|
||||
|
||||
#include <QtMultimedia/QMediaPlayer>
|
||||
|
||||
namespace Jellyfin {
|
||||
namespace DeviceProfile {
|
||||
QJsonObject generateProfile();
|
||||
// Transport
|
||||
bool supportsHls();
|
||||
|
||||
// Bitrate
|
||||
int maxStreamingBitrate();
|
||||
|
||||
// Video codecs
|
||||
bool canPlayH264();
|
||||
bool canPlayH265();
|
||||
|
||||
// Audio codecs
|
||||
bool canPlayAc3();
|
||||
bool supportsMp3VideoAudio();
|
||||
}
|
||||
}
|
||||
|
||||
#endif // JELLYFIN_DEVICE_PROFILE_H
|
354
core/include/jellyfinitem.h
Normal file
354
core/include/jellyfinitem.h
Normal file
|
@ -0,0 +1,354 @@
|
|||
#ifndef JELLYFINITEM_H
|
||||
#define JELLYFINITEM_H
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonValue>
|
||||
|
||||
#include <QDebug>
|
||||
#include <QMetaObject>
|
||||
#include <QMetaProperty>
|
||||
#include <QDateTime>
|
||||
#include <QObject>
|
||||
#include <QRegularExpression>
|
||||
#include <QtQml>
|
||||
|
||||
#include <QNetworkReply>
|
||||
|
||||
#include <optional>
|
||||
|
||||
#include <cmath>
|
||||
|
||||
#include "jellyfinapiclient.h"
|
||||
|
||||
namespace Jellyfin {
|
||||
|
||||
/**
|
||||
* @brief Base class for a serializable object.
|
||||
*
|
||||
* This class will be (de)serialized based on its properties.
|
||||
* Note: it must have a constructor without arguments marked with Q_INVOKABLE
|
||||
*/
|
||||
class JsonSerializable : public QObject {
|
||||
Q_OBJECT
|
||||
public:
|
||||
Q_INVOKABLE JsonSerializable(QObject *parent);
|
||||
|
||||
/**
|
||||
* @brief Sets this objects properties based on obj.
|
||||
* @param obj The data to load into this object.
|
||||
*/
|
||||
void deserialize(const QJsonObject &obj);
|
||||
QJsonObject serialize() const;
|
||||
private:
|
||||
QVariant jsonToVariant(QMetaProperty prop, const QJsonValue &val, const QJsonObject &root);
|
||||
QJsonValue variantToJson(const QVariant var) const;
|
||||
|
||||
/**
|
||||
* @brief Sets the first letter of the string to lower case (to make it camelCase).
|
||||
* @param str The string to modify
|
||||
* @return THe modified string
|
||||
*/
|
||||
static QString fromPascalCase(QString str);
|
||||
/**
|
||||
* @brief Sets the first letter of the string to uper case (to make it PascalCase).
|
||||
* @param str The string to modify
|
||||
* @return THe modified string
|
||||
*/
|
||||
static QString toPascalCase(QString str);
|
||||
|
||||
static const QRegularExpression m_listExpression;
|
||||
/**
|
||||
* @brief Qt is doing weird. I'll keep track of the metatypes myself.
|
||||
*/
|
||||
QHash<QString, const QMetaType *> m_nameMetatypeMap;
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* @brief An "interface" for a remote data source
|
||||
*
|
||||
* This class is basically a base class for JSON data that can be fetched from over the network.
|
||||
* Subclasses should reimplement reload and call setStatus to update the QML part of the code
|
||||
* appropiatly.
|
||||
*/
|
||||
class RemoteData : public JsonSerializable {
|
||||
Q_OBJECT
|
||||
public:
|
||||
enum Status {
|
||||
/// The data is unitialized and not loading either.
|
||||
Uninitialised,
|
||||
/// The data is being loaded over the network
|
||||
Loading,
|
||||
/// The data is ready, the properties in this object are up to date.
|
||||
Ready,
|
||||
/// An error has occurred while loading the data. See error() for more details.
|
||||
Error
|
||||
};
|
||||
Q_ENUM(Status)
|
||||
|
||||
explicit RemoteData(QObject *parent = nullptr);
|
||||
|
||||
Q_PROPERTY(ApiClient *apiClient MEMBER m_apiClient WRITE setApiClient NOTIFY apiClientChanged STORED false)
|
||||
Q_PROPERTY(Status status READ status NOTIFY statusChanged STORED false)
|
||||
Q_PROPERTY(QNetworkReply::NetworkError error READ error NOTIFY errorChanged STORED false)
|
||||
Q_PROPERTY(QString errorString READ errorString NOTIFY errorStringChanged STORED false)
|
||||
|
||||
Status status() const { return m_status; }
|
||||
QNetworkReply::NetworkError error() const { return m_error; }
|
||||
QString errorString() const { return m_errorString; }
|
||||
|
||||
void setApiClient(ApiClient *newApiClient);
|
||||
signals:
|
||||
void statusChanged(Status newStatus);
|
||||
void apiClientChanged(ApiClient *newApiClient);
|
||||
void errorChanged(QNetworkReply::NetworkError newError);
|
||||
void errorStringChanged(QString newErrorString);
|
||||
public slots:
|
||||
virtual void reload() = 0;
|
||||
protected:
|
||||
void setStatus(Status newStatus);
|
||||
void setError(QNetworkReply::NetworkError error);
|
||||
void setErrorString(const QString &newErrorString);
|
||||
ApiClient *m_apiClient = nullptr;
|
||||
private:
|
||||
Status m_status = Uninitialised;
|
||||
QNetworkReply::NetworkError m_error = QNetworkReply::NoError;
|
||||
QString m_errorString;
|
||||
};
|
||||
|
||||
class MediaStream : public JsonSerializable {
|
||||
Q_OBJECT
|
||||
public:
|
||||
Q_INVOKABLE explicit MediaStream(QObject *parent = nullptr);
|
||||
MediaStream(const MediaStream &other);
|
||||
bool operator==(const MediaStream &other);
|
||||
virtual ~MediaStream() { qDebug() << "MediaStream destroyed"; }
|
||||
|
||||
enum MediaStreamType {
|
||||
Undefined,
|
||||
Audio,
|
||||
Video,
|
||||
Subtitle,
|
||||
EmbeddedImage
|
||||
};
|
||||
Q_ENUM(MediaStreamType)
|
||||
|
||||
Q_PROPERTY(QString codec MEMBER m_codec NOTIFY codecChanged)
|
||||
Q_PROPERTY(QString codecTag MEMBER m_codecTag NOTIFY codecTagChanged)
|
||||
Q_PROPERTY(QString language MEMBER m_language NOTIFY languageChanged)
|
||||
Q_PROPERTY(QString displayTitle MEMBER m_displayTitle NOTIFY displayTitleChanged)
|
||||
Q_PROPERTY(MediaStreamType type MEMBER m_type NOTIFY typeChanged)
|
||||
Q_PROPERTY(int index MEMBER m_index NOTIFY indexChanged)
|
||||
signals:
|
||||
void codecChanged(const QString &newCodec);
|
||||
void codecTagChanged(const QString &newCodecTag);
|
||||
void languageChanged(const QString &newLanguage);
|
||||
void displayTitleChanged(const QString &newDisplayTitle);
|
||||
void typeChanged(MediaStreamType newType);
|
||||
void indexChanged(int newIndex);
|
||||
private:
|
||||
QString m_codec;
|
||||
QString m_codecTag;
|
||||
QString m_language;
|
||||
QString m_displayTitle;
|
||||
MediaStreamType m_type = Undefined;
|
||||
int m_index = -1;
|
||||
};
|
||||
|
||||
class Item : public RemoteData {
|
||||
Q_OBJECT
|
||||
public:
|
||||
Q_INVOKABLE explicit Item(QObject *parent = nullptr);
|
||||
|
||||
Q_PROPERTY(QString jellyfinId READ jellyfinId WRITE setJellyfinId NOTIFY jellyfinIdChanged)
|
||||
|
||||
// Based on https://github.com/jellyfin/jellyfin/blob/907695dec7fda152d0e17c1197637bc0e17c9928/MediaBrowser.Model/Dto/BaseItemDto.cs
|
||||
// I copy, pasted and replaced. I feel like a Go programmer implementing generic containers.
|
||||
// If this were D, I would've writed a compile-time C# parser to parse that source code at compile time, extract
|
||||
// the properties and generate a class based on that.
|
||||
// Doing that in C++ would be more difficult and I dislike qmake. Does it even support running programs at compile time?
|
||||
// But here I am, using ctrl-C++
|
||||
Q_PROPERTY(QString name MEMBER m_name NOTIFY nameChanged)
|
||||
Q_PROPERTY(QString originalTitle MEMBER m_originalTitle NOTIFY originalTitleChanged)
|
||||
Q_PROPERTY(QString serverId MEMBER m_serverId NOTIFY serverIdChanged)
|
||||
Q_PROPERTY(QString etag MEMBER m_etag NOTIFY etagChanged)
|
||||
Q_PROPERTY(QString sourceType MEMBER m_sourceType NOTIFY sourceTypeChanged)
|
||||
Q_PROPERTY(QString playlistItemId MEMBER m_playlistItemId NOTIFY playlistItemIdChanged)
|
||||
Q_PROPERTY(QDateTime dateCreated MEMBER m_dateCreated NOTIFY dateCreatedChanged)
|
||||
Q_PROPERTY(QDateTime dateLastMediaAdded MEMBER m_dateLastMediaAdded NOTIFY dateLastMediaAddedChanged)
|
||||
Q_PROPERTY(QString extraType MEMBER m_extraType NOTIFY extraTypeChanged)
|
||||
Q_PROPERTY(int airsBeforeSeasonNumber READ airsBeforeSeasonNumber WRITE setAirsBeforeSeasonNumber NOTIFY airsBeforeSeasonNumberChanged)
|
||||
Q_PROPERTY(int airsAfterSeasonNumber READ airsAfterSeasonNumber WRITE setAirsAfterSeasonNumber NOTIFY airsAfterSeasonNumberChanged)
|
||||
Q_PROPERTY(int airsBeforeEpisodeNumber READ airsBeforeEpisodeNumber WRITE setAirsBeforeEpisodeNumber NOTIFY airsBeforeEpisodeNumberChanged)
|
||||
Q_PROPERTY(bool canDelete READ canDelete WRITE setCanDelete NOTIFY canDeleteChanged)
|
||||
Q_PROPERTY(bool canDownload READ canDownload WRITE setCanDownload NOTIFY canDownloadChanged)
|
||||
Q_PROPERTY(bool hasSubtitles READ hasSubtitles WRITE setHasSubtitles NOTIFY hasSubtitlesChanged)
|
||||
Q_PROPERTY(QString preferredMetadataLanguage MEMBER m_preferredMetadataLanguage NOTIFY preferredMetadataLanguageChanged)
|
||||
Q_PROPERTY(QString preferredMetadataCountryCode MEMBER m_preferredMetadataCountryCode NOTIFY preferredMetadataCountryCodeChanged)
|
||||
Q_PROPERTY(bool supportsSync READ supportsSync WRITE setSupportsSync NOTIFY supportsSyncChanged)
|
||||
Q_PROPERTY(QString container MEMBER m_container NOTIFY containerChanged)
|
||||
Q_PROPERTY(QString sortName MEMBER m_sortName NOTIFY sortNameChanged)
|
||||
Q_PROPERTY(QString forcedSortName MEMBER m_forcedSortName NOTIFY forcedSortNameChanged)
|
||||
//SKIP: Video3DFormat
|
||||
Q_PROPERTY(QDateTime premiereData MEMBER m_premiereDate NOTIFY premiereDateChanged)
|
||||
//SKIP: ExternalUrls
|
||||
//SKIP: MediaSources
|
||||
Q_PROPERTY(float criticRating READ criticRating WRITE setCriticRating NOTIFY criticRatingChanged)
|
||||
Q_PROPERTY(QStringList productionLocations MEMBER m_productionLocations NOTIFY productionLocationsChanged)
|
||||
|
||||
// Handpicked, important ones
|
||||
Q_PROPERTY(qint64 runTimeTicks READ runTimeTicks WRITE setRunTimeTicks NOTIFY runTimeTicksChanged)
|
||||
Q_PROPERTY(QString overview MEMBER m_overview NOTIFY overviewChanged)
|
||||
Q_PROPERTY(int productionYear READ productionYear WRITE setProductionYear NOTIFY productionYearChanged)
|
||||
Q_PROPERTY(int indexNumber READ indexNumber WRITE setIndexNumber NOTIFY indexNumberChanged)
|
||||
Q_PROPERTY(int indexNumberEnd READ indexNumberEnd WRITE setIndexNumberEnd NOTIFY indexNumberEndChanged)
|
||||
Q_PROPERTY(bool isFolder READ isFolder WRITE setIsFolder NOTIFY isFolderChanged)
|
||||
Q_PROPERTY(QString type MEMBER m_type NOTIFY typeChanged)
|
||||
Q_PROPERTY(QString seriesName MEMBER m_seriesName NOTIFY seriesNameChanged)
|
||||
Q_PROPERTY(QString seasonName MEMBER m_seasonName NOTIFY seasonNameChanged)
|
||||
Q_PROPERTY(QList<MediaStream *> __list__mediaStreams MEMBER __list__m_mediaStreams NOTIFY mediaStreamsChanged)
|
||||
Q_PROPERTY(QVariantList mediaStreams MEMBER m_mediaStreams NOTIFY mediaStreamsChanged STORED false)
|
||||
|
||||
QString jellyfinId() const { return m_id; }
|
||||
void setJellyfinId(QString newId);
|
||||
|
||||
int airsBeforeSeasonNumber() const { return m_airsBeforeSeasonNumber.value_or(-1); }
|
||||
void setAirsBeforeSeasonNumber(int newAirsBeforeSeasonNumber) { m_airsBeforeSeasonNumber = newAirsBeforeSeasonNumber; emit airsBeforeSeasonNumberChanged(newAirsBeforeSeasonNumber); }
|
||||
int airsAfterSeasonNumber() const { return m_airsAfterSeasonNumber.value_or(-1); }
|
||||
void setAirsAfterSeasonNumber(int newAirsAfterSeasonNumber) { m_airsAfterSeasonNumber = newAirsAfterSeasonNumber; emit airsAfterSeasonNumberChanged(newAirsAfterSeasonNumber); }
|
||||
int airsBeforeEpisodeNumber() const { return m_airsBeforeEpisodeNumber.value_or(-1); }
|
||||
void setAirsBeforeEpisodeNumber(int newAirsBeforeEpisodeNumber) { m_airsBeforeEpisodeNumber = newAirsBeforeEpisodeNumber; emit airsBeforeEpisodeNumberChanged(newAirsBeforeEpisodeNumber); }
|
||||
|
||||
bool canDelete() const { return m_canDelete.value_or(false); }
|
||||
void setCanDelete(bool newCanDelete) { m_canDelete = newCanDelete; emit canDeleteChanged(newCanDelete); }
|
||||
bool canDownload() const { return m_canDownload.value_or(false); }
|
||||
void setCanDownload(bool newCanDownload) { m_canDownload = newCanDownload; emit canDownloadChanged(newCanDownload); }
|
||||
bool hasSubtitles() const { return m_hasSubtitles.value_or(false); }
|
||||
void setHasSubtitles(bool newHasSubtitles) { m_hasSubtitles = newHasSubtitles; emit hasSubtitlesChanged(newHasSubtitles); }
|
||||
bool supportsSync() const { return m_supportsSync.value_or(false); }
|
||||
void setSupportsSync(bool newSupportsSync) { m_supportsSync = newSupportsSync; emit supportsSyncChanged(newSupportsSync); }
|
||||
float criticRating() const { return m_criticRating.value_or(std::nanf("")); }
|
||||
void setCriticRating(float newCriticRating) { m_criticRating = newCriticRating; emit criticRatingChanged(newCriticRating); }
|
||||
|
||||
// Handpicked, important ones
|
||||
qint64 runTimeTicks() const { return m_runTimeTicks.value_or(-1); }
|
||||
void setRunTimeTicks(qint64 newRunTimeTicks) { m_runTimeTicks = newRunTimeTicks; emit runTimeTicksChanged(newRunTimeTicks); }
|
||||
int productionYear() const { return m_productionYear.value_or(-1); }
|
||||
void setProductionYear(int newProductionYear) { m_productionYear = std::optional<int>(newProductionYear); emit productionYearChanged(newProductionYear); }
|
||||
int indexNumber() const { return m_indexNumber.value_or(-1); }
|
||||
void setIndexNumber(int newIndexNumber) { m_indexNumber = std::optional<int>(newIndexNumber); emit indexNumberChanged(newIndexNumber); }
|
||||
int indexNumberEnd() const { return m_indexNumberEnd.value_or(-1); }
|
||||
void setIndexNumberEnd(int newIndexNumberEnd) { m_indexNumberEnd = std::optional<int>(newIndexNumberEnd); emit indexNumberEndChanged(newIndexNumberEnd); }
|
||||
bool isFolder() const { return m_isFolder.value_or(false); }
|
||||
void setIsFolder(bool newIsFolder) { m_isFolder = newIsFolder; emit isFolderChanged(newIsFolder); }
|
||||
|
||||
//QQmlListProperty<MediaStream> mediaStreams() { return toReadOnlyQmlListProperty<MediaStream>(m_mediaStreams); }
|
||||
//QList<QObject *> mediaStreams() { return *reinterpret_cast<QList<QObject *> *>(&m_mediaStreams); }
|
||||
QVariantList mediaStreams() { QVariantList l; for (auto e: m_mediaStreams) l.append(QVariant::fromValue(e)); return l;}
|
||||
|
||||
signals:
|
||||
void jellyfinIdChanged(const QString &newId);
|
||||
void nameChanged(const QString &newName);
|
||||
void originalTitleChanged(const QString &newOriginalTitle);
|
||||
void serverIdChanged(const QString &newServerId);
|
||||
void etagChanged(const QString &newEtag);
|
||||
void sourceTypeChanged(const QString &sourceType);
|
||||
void playlistItemIdChanged(const QString &playlistItemIdChanged);
|
||||
void dateCreatedChanged(QDateTime newDateCreatedChanged);
|
||||
void dateLastMediaAddedChanged(QDateTime newDateLastMediaAdded);
|
||||
void extraTypeChanged(const QString &newExtraType);
|
||||
void airsBeforeSeasonNumberChanged(int newAirsBeforeSeasonNumber);
|
||||
void airsAfterSeasonNumberChanged(int newAirsAfterSeasonNumber);
|
||||
void airsBeforeEpisodeNumberChanged(int newAirsAfterEpisodeNumber);
|
||||
bool canDeleteChanged(bool newCanDelete);
|
||||
void canDownloadChanged(bool newCanDownload);
|
||||
void hasSubtitlesChanged(bool newHasSubtitles);
|
||||
void preferredMetadataLanguageChanged(const QString &newPreferredMetadataLanguage);
|
||||
void preferredMetadataCountryCodeChanged(const QString &newPreferredMetadataCountryCode);
|
||||
void supportsSyncChanged(bool newSupportsSync);
|
||||
void containerChanged(const QString &newContainer);
|
||||
void sortNameChanged(const QString &newSortName);
|
||||
void forcedSortNameChanged(const QString &newForcedSortName);
|
||||
void premiereDateChanged(QDateTime newPremiereDate);
|
||||
void criticRatingChanged(float newCriticRating);
|
||||
void productionLocationsChanged(QStringList newProductionLocations);
|
||||
|
||||
// Handpicked, important ones
|
||||
void runTimeTicksChanged(qint64 newRunTimeTicks);
|
||||
void overviewChanged(const QString &newOverview);
|
||||
void productionYearChanged(int newProductionYear);
|
||||
void indexNumberChanged(int newIndexNumber);
|
||||
void indexNumberEndChanged(int newIndexNumberEnd);
|
||||
void isFolderChanged(bool newIsFolder);
|
||||
void typeChanged(const QString &newType);
|
||||
void seriesNameChanged(const QString &newSeriesName);
|
||||
void seasonNameChanged(const QString &newSeasonName);
|
||||
void mediaStreamsChanged(/*const QList<MediaStream *> &newMediaStreams*/);
|
||||
|
||||
public slots:
|
||||
/**
|
||||
* @brief (Re)loads the item from the Jellyfin server.
|
||||
*/
|
||||
void reload() override;
|
||||
protected:
|
||||
QString m_id;
|
||||
QString m_name;
|
||||
QString m_originalTitle;
|
||||
QString m_serverId;
|
||||
QString m_etag;
|
||||
QString m_sourceType;
|
||||
QString m_playlistItemId;
|
||||
QDateTime m_dateCreated;
|
||||
QDateTime m_dateLastMediaAdded;
|
||||
QString m_extraType;
|
||||
std::optional<int> m_airsBeforeSeasonNumber = std::nullopt;
|
||||
std::optional<int> m_airsAfterSeasonNumber = std::nullopt;
|
||||
std::optional<int> m_airsBeforeEpisodeNumber = std::nullopt;
|
||||
std::optional<bool> m_canDelete = std::nullopt;
|
||||
std::optional<bool> m_canDownload = std::nullopt;
|
||||
std::optional<bool> m_hasSubtitles = std::nullopt;
|
||||
QString m_preferredMetadataLanguage;
|
||||
QString m_preferredMetadataCountryCode;
|
||||
std::optional<bool> m_supportsSync = std::nullopt;
|
||||
QString m_container;
|
||||
QString m_sortName;
|
||||
QString m_forcedSortName;
|
||||
QDateTime m_premiereDate;
|
||||
std::optional<float> m_criticRating = std::nullopt;
|
||||
QStringList m_productionLocations;
|
||||
|
||||
// Handpicked, important ones
|
||||
std::optional<qint64> m_runTimeTicks = std::nullopt;
|
||||
QString m_overview;
|
||||
std::optional<int> m_productionYear = std::nullopt;
|
||||
std::optional<int> m_indexNumber = std::nullopt;
|
||||
std::optional<int> m_indexNumberEnd = std::nullopt;
|
||||
std::optional<bool> m_isFolder = std::nullopt;
|
||||
QString m_type;
|
||||
QString m_seriesName;
|
||||
QString m_seasonName;
|
||||
QList<MediaStream *> __list__m_mediaStreams;
|
||||
QVariantList m_mediaStreams;
|
||||
|
||||
template<typename T>
|
||||
QQmlListProperty<T> toReadOnlyQmlListProperty(QList<T *> &list) {
|
||||
return QQmlListProperty<T>(this, std::addressof(list), &qlist_count, &qlist_at);
|
||||
}
|
||||
|
||||
template<typename T>
|
||||
static int qlist_count(QQmlListProperty<T> *p) {
|
||||
return reinterpret_cast<QList<T *> *>(p->data)->count();
|
||||
}
|
||||
|
||||
template<typename T>
|
||||
static T *qlist_at(QQmlListProperty<T> *p, int idx) {
|
||||
return reinterpret_cast<QList<T *> *>(p->data)->at(idx);
|
||||
}
|
||||
};
|
||||
|
||||
void registerSerializableJsonTypes(const char* URI);
|
||||
}
|
||||
|
||||
#endif // JELLYFINITEM_H
|
112
core/include/jellyfinplaybackmanager.h
Normal file
112
core/include/jellyfinplaybackmanager.h
Normal file
|
@ -0,0 +1,112 @@
|
|||
/*
|
||||
Sailfin: a Jellyfin client written using Qt
|
||||
Copyright (C) 2020 Chris Josten
|
||||
|
||||
This library is free software; you can redistribute it and/or
|
||||
modify it under the terms of the GNU Lesser General Public
|
||||
License as published by the Free Software Foundation; either
|
||||
version 2.1 of the License, or (at your option) any later version.
|
||||
|
||||
This library is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
Lesser General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Lesser General Public
|
||||
License along with this library; if not, write to the Free Software
|
||||
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
*/
|
||||
|
||||
#ifndef JELLYFIN_MEDIA_SOURCE_H
|
||||
#define JELLYFIN_MEDIA_SOURCE_H
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
#include <QObject>
|
||||
#include <QVariant>
|
||||
|
||||
#include <QUrlQuery>
|
||||
|
||||
#include <QtMultimedia/QMediaPlayer>
|
||||
|
||||
|
||||
#include "jellyfinapiclient.h"
|
||||
|
||||
namespace Jellyfin {
|
||||
|
||||
class PlaybackManager : public QObject {
|
||||
Q_OBJECT
|
||||
public:
|
||||
enum PlayMethod {
|
||||
Transcode,
|
||||
Stream,
|
||||
DirectPlay
|
||||
};
|
||||
Q_ENUM(PlayMethod)
|
||||
|
||||
explicit PlaybackManager(QObject *parent = nullptr);
|
||||
Q_PROPERTY(ApiClient *apiClient MEMBER m_apiClient)
|
||||
Q_PROPERTY(QString itemId READ itemId WRITE setItemId NOTIFY itemIdChanged)
|
||||
Q_PROPERTY(QString streamUrl READ streamUrl NOTIFY streamUrlChanged)
|
||||
Q_PROPERTY(bool autoOpen MEMBER m_autoOpen NOTIFY autoOpenChanged)
|
||||
Q_PROPERTY(int audioIndex MEMBER m_audioIndex NOTIFY audioIndexChanged)
|
||||
Q_PROPERTY(int subtitleIndex MEMBER m_subtitleIndex NOTIFY subtitleIndexChanged)
|
||||
Q_PROPERTY(qint64 position MEMBER m_position WRITE setPosition NOTIFY positionChanged)
|
||||
Q_PROPERTY(QMediaPlayer::State state READ state WRITE setState NOTIFY stateChanged)
|
||||
|
||||
QString itemId() const { return m_itemId; }
|
||||
void setItemId(const QString &newItemId);
|
||||
|
||||
QMediaPlayer::State state() const { return m_state; }
|
||||
void setState(QMediaPlayer::State newState);
|
||||
|
||||
void setPosition(qint64 position);
|
||||
|
||||
QString streamUrl() const { return m_streamUrl; }
|
||||
signals:
|
||||
void itemIdChanged(const QString &newItemId);
|
||||
void streamUrlChanged(const QString &newStreamUrl);
|
||||
void autoOpenChanged(bool autoOpen);
|
||||
void audioIndexChanged(int audioIndex);
|
||||
void subtitleIndexChanged(int subtitleIndex);
|
||||
void positionChanged(qint64 position);
|
||||
void stateChanged(QMediaPlayer::State state);
|
||||
|
||||
public slots:
|
||||
void updatePlaybackInfo();
|
||||
|
||||
private:
|
||||
QTimer m_updateTimer;
|
||||
ApiClient *m_apiClient = nullptr;
|
||||
QString m_itemId;
|
||||
QString m_streamUrl;
|
||||
QString m_playSessionId;
|
||||
int m_audioIndex = 0;
|
||||
int m_subtitleIndex = -1;
|
||||
qint64 m_position = 0;
|
||||
qint64 m_stopPosition = 0;
|
||||
PlayMethod m_playMethod;
|
||||
QMediaPlayer::State m_state = QMediaPlayer::StoppedState;
|
||||
|
||||
/**
|
||||
* @brief Whether to automatically open the livestream of the item;
|
||||
*/
|
||||
bool m_autoOpen = false;
|
||||
|
||||
void fetchStreamUrl();
|
||||
void setStreamUrl(const QString &streamUrl);
|
||||
|
||||
// Factor to multiply with when converting from milliseconds to ticks.
|
||||
const int MS_TICK_FACTOR = 10000;
|
||||
|
||||
enum PlaybackInfoType { Started, Stopped, Progress };
|
||||
|
||||
/**
|
||||
* @brief Posts the playback information
|
||||
*/
|
||||
void postPlaybackInfo(PlaybackInfoType type);
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
#endif // JELLYFIN_MEDIA_SOURCE_H
|
77
core/include/jellyfinwebsocket.h
Normal file
77
core/include/jellyfinwebsocket.h
Normal file
|
@ -0,0 +1,77 @@
|
|||
/*
|
||||
Sailfin: a Jellyfin client written using Qt
|
||||
Copyright (C) 2020 Chris Josten
|
||||
|
||||
This library is free software; you can redistribute it and/or
|
||||
modify it under the terms of the GNU Lesser General Public
|
||||
License as published by the Free Software Foundation; either
|
||||
version 2.1 of the License, or (at your option) any later version.
|
||||
|
||||
This library is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
Lesser General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Lesser General Public
|
||||
License along with this library; if not, write to the Free Software
|
||||
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
*/
|
||||
#ifndef JELLYFIN_WEBSOCKET_H
|
||||
#define JELLYFIN_WEBSOCKET_H
|
||||
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonValue>
|
||||
#include <QDebug>
|
||||
#include <QObject>
|
||||
#include <QtGlobal>
|
||||
#include <QTimer>
|
||||
#include <QUuid>
|
||||
|
||||
#include <QtWebSockets/QWebSocket>
|
||||
|
||||
#include "jellyfinapiclient.h"
|
||||
|
||||
namespace Jellyfin {
|
||||
class ApiClient;
|
||||
class WebSocket : public QObject {
|
||||
Q_OBJECT
|
||||
public:
|
||||
/**
|
||||
* @brief WebSocket creates a webSocket for a Jellyfin server to handle real time updates.
|
||||
* @param client The client to create the socket for.
|
||||
*
|
||||
* The socket will automatically set the ApiClient to its parent.
|
||||
*/
|
||||
explicit WebSocket(ApiClient *client);
|
||||
enum MessageType {
|
||||
ForceKeepAlive,
|
||||
KeepAlive
|
||||
};
|
||||
Q_ENUM(MessageType)
|
||||
public slots:
|
||||
void open();
|
||||
private slots:
|
||||
void textMessageReceived(const QString &message);
|
||||
void onConnected();
|
||||
void onDisconnected();
|
||||
|
||||
void sendKeepAlive();
|
||||
signals:
|
||||
void commandReceived(QString arts, QVariantMap args);
|
||||
|
||||
protected:
|
||||
ApiClient *m_apiClient;
|
||||
QWebSocket m_webSocket;
|
||||
|
||||
QTimer m_keepAliveTimer;
|
||||
|
||||
|
||||
void setupKeepAlive(int data);
|
||||
void sendMessage(MessageType type, QJsonValue data = QJsonValue());
|
||||
QString generateMessageId();
|
||||
};
|
||||
}
|
||||
|
||||
#endif // JELLYFIN_WEBSOCKET_H
|
83
core/include/serverdiscoverymodel.h
Normal file
83
core/include/serverdiscoverymodel.h
Normal file
|
@ -0,0 +1,83 @@
|
|||
/*
|
||||
Sailfin: a Jellyfin client written using Qt
|
||||
Copyright (C) 2020 Chris Josten
|
||||
|
||||
This library is free software; you can redistribute it and/or
|
||||
modify it under the terms of the GNU Lesser General Public
|
||||
License as published by the Free Software Foundation; either
|
||||
version 2.1 of the License, or (at your option) any later version.
|
||||
|
||||
This library is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
Lesser General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Lesser General Public
|
||||
License along with this library; if not, write to the Free Software
|
||||
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
*/
|
||||
|
||||
#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>
|
||||
|
||||
namespace Jellyfin {
|
||||
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;
|
||||
|
||||
std::vector<ServerDiscovery> m_discoveredServers;
|
||||
QUdpSocket m_socket;
|
||||
};
|
||||
}
|
||||
#endif //SERVER_DISCOVERY_MODEL_H
|
85
core/src/credentialmanager.cpp
Normal file
85
core/src/credentialmanager.cpp
Normal file
|
@ -0,0 +1,85 @@
|
|||
/*
|
||||
Sailfin: a Jellyfin client written using Qt
|
||||
Copyright (C) 2020 Chris Josten
|
||||
|
||||
This library is free software; you can redistribute it and/or
|
||||
modify it under the terms of the GNU Lesser General Public
|
||||
License as published by the Free Software Foundation; either
|
||||
version 2.1 of the License, or (at your option) any later version.
|
||||
|
||||
This library is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
Lesser General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Lesser General Public
|
||||
License along with this library; if not, write to the Free Software
|
||||
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
*/
|
||||
|
||||
#include "credentialmanager.h"
|
||||
|
||||
CredentialsManager * CredentialsManager::newInstance(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) + "/users/" + user);
|
||||
|
||||
// Check if only the /address key is left. In this case, the server section should be removed.
|
||||
m_settings.beginGroup(urlToGroupName(server));
|
||||
int childGroupsCount = m_settings.childGroups().count();
|
||||
m_settings.endGroup();
|
||||
if (childGroupsCount == 0) {
|
||||
m_settings.remove(urlToGroupName(server));
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
19
core/src/jellyfin.cpp
Normal file
19
core/src/jellyfin.cpp
Normal file
|
@ -0,0 +1,19 @@
|
|||
#include "jellyfin.h"
|
||||
namespace Jellyfin {
|
||||
|
||||
void registerTypes() {
|
||||
const char* QML_NAMESPACE = "nl.netsoj.chris.Jellyfin";
|
||||
// Singletons are perhaps bad, but they are convenient :)
|
||||
qmlRegisterSingletonType<Jellyfin::ApiClient>(QML_NAMESPACE, 1, 0, "ApiClient", [](QQmlEngine *eng, QJSEngine *js) {
|
||||
Q_UNUSED(eng)
|
||||
Q_UNUSED(js)
|
||||
return dynamic_cast<QObject*>(new Jellyfin::ApiClient());
|
||||
});
|
||||
qmlRegisterType<Jellyfin::ServerDiscoveryModel>(QML_NAMESPACE, 1, 0, "ServerDiscoveryModel");
|
||||
qmlRegisterType<Jellyfin::PlaybackManager>(QML_NAMESPACE, 1, 0, "PlaybackManager");
|
||||
|
||||
// API models
|
||||
Jellyfin::registerModels(QML_NAMESPACE);
|
||||
Jellyfin::registerSerializableJsonTypes(QML_NAMESPACE);
|
||||
}
|
||||
}
|
283
core/src/jellyfinapiclient.cpp
Normal file
283
core/src/jellyfinapiclient.cpp
Normal file
|
@ -0,0 +1,283 @@
|
|||
/*
|
||||
Sailfin: a Jellyfin client written using Qt
|
||||
Copyright (C) 2020 Chris Josten
|
||||
|
||||
This library is free software; you can redistribute it and/or
|
||||
modify it under the terms of the GNU Lesser General Public
|
||||
License as published by the Free Software Foundation; either
|
||||
version 2.1 of the License, or (at your option) any later version.
|
||||
|
||||
This library is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
Lesser General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Lesser General Public
|
||||
License along with this library; if not, write to the Free Software
|
||||
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
*/
|
||||
|
||||
#include "jellyfinapiclient.h"
|
||||
|
||||
namespace Jellyfin {
|
||||
|
||||
ApiClient::ApiClient(QObject *parent)
|
||||
: QObject(parent),
|
||||
m_webSocket(new WebSocket(this)) {
|
||||
m_deviceName = QHostInfo::localHostName();
|
||||
m_deviceId = QUuid::createUuid().toString(); // TODO: make this not random?
|
||||
m_credManager = CredentialsManager::newInstance(this);
|
||||
|
||||
generateDeviceProfile();
|
||||
}
|
||||
|
||||
QString ApiClient::version() const {
|
||||
return QString(SAILFIN_VERSION);
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// BASE HTTP METHODS //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
|
||||
void ApiClient::addBaseRequestHeaders(QNetworkRequest &request, const QString &path, const QUrlQuery ¶ms) {
|
||||
addTokenHeader(request);
|
||||
request.setRawHeader("Accept", "application/json;"); // profile=\"CamelCase\"");
|
||||
request.setHeader(QNetworkRequest::UserAgentHeader, QString("Sailfin/%1").arg(version()));
|
||||
QString url = this->m_baseUrl + path;
|
||||
if (!params.isEmpty()) url += "?" + params.toString();
|
||||
request.setUrl(url);
|
||||
}
|
||||
|
||||
void ApiClient::addTokenHeader(QNetworkRequest &request) {
|
||||
QString authentication = "MediaBrowser ";
|
||||
authentication += "Client=\"Sailfin\"";
|
||||
authentication += ", Device=\"" + m_deviceName + "\"";
|
||||
authentication += ", DeviceId=\"" + m_deviceId + "\"";
|
||||
authentication += ", Version=\"" + version() + "\"";
|
||||
if (m_authenticated) {
|
||||
authentication += ", token=\"" + m_token + "\"";
|
||||
}
|
||||
request.setRawHeader("X-Emby-Authorization", authentication.toUtf8());
|
||||
}
|
||||
|
||||
QNetworkReply *ApiClient::get(const QString &path, const QUrlQuery ¶ms) {
|
||||
QNetworkRequest req;
|
||||
addBaseRequestHeaders(req, path, params);
|
||||
qDebug() << "GET " << req.url();
|
||||
return m_naManager.get(req);
|
||||
}
|
||||
QNetworkReply *ApiClient::post(const QString &path, const QJsonDocument &data, const QUrlQuery ¶ms) {
|
||||
|
||||
QNetworkRequest req;
|
||||
req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
|
||||
addBaseRequestHeaders(req, path, params);
|
||||
qDebug() << "POST " << req.url();
|
||||
if (data.isEmpty())
|
||||
return m_naManager.post(req, QByteArray());
|
||||
else {
|
||||
return m_naManager.post(req, data.toJson(QJsonDocument::Compact));
|
||||
}
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// Nice to have methods //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
void ApiClient::restoreSavedSession(){
|
||||
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);
|
||||
this->postCapabilities();
|
||||
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 ApiClient::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.
|
||||
QNetworkRequest req = QNetworkRequest(m_baseUrl);
|
||||
req.setAttribute(QNetworkRequest::FollowRedirectsAttribute, true);
|
||||
QNetworkReply *rep = m_naManager.get(req);
|
||||
connect(rep, &QNetworkReply::finished, this, [rep, this](){
|
||||
int status = statusCode(rep);
|
||||
qDebug() << status;
|
||||
|
||||
QString newUrl = rep->url().toString();
|
||||
// If the server wants to redirect us to their web interface, we have to chop the last part of the url off.
|
||||
if (newUrl.endsWith("/web/index.html")) {
|
||||
newUrl.chop(QString("/web/index.html").size());
|
||||
this->setBaseUrl(newUrl);
|
||||
}
|
||||
this->getBrandingConfiguration();
|
||||
rep->deleteLater();
|
||||
});
|
||||
connect(rep, &QNetworkReply::redirected, this, [req] (const QUrl &url) {
|
||||
qDebug() << "Redirect from " << req.url() << " to " << url;
|
||||
});
|
||||
setDefaultErrorHandler(rep);
|
||||
}
|
||||
|
||||
void ApiClient::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();
|
||||
});
|
||||
setDefaultErrorHandler(rep);
|
||||
}
|
||||
|
||||
void ApiClient::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();
|
||||
|
||||
// Fool this class's addRequestheaders to add the token, without
|
||||
// notifying QML that we're authenticated, to prevent other requests going first.
|
||||
this->m_authenticated = true;
|
||||
this->setUserId(authInfo["User"].toObject()["Id"].toString());
|
||||
this->postCapabilities();
|
||||
this->setAuthenticated(true);
|
||||
|
||||
if (storeCredentials) {
|
||||
m_credManager->store(this->m_baseUrl, this->m_userId, this->m_token);
|
||||
}
|
||||
}
|
||||
rep->deleteLater();
|
||||
});
|
||||
setDefaultErrorHandler(rep);
|
||||
}
|
||||
|
||||
void ApiClient::deleteSession() {
|
||||
QNetworkReply *rep = post("/Sessions/Logout");
|
||||
connect(rep, &QNetworkReply::finished, this, [rep, this] {
|
||||
m_credManager->remove(m_baseUrl, m_userId);
|
||||
this->setAuthenticated(false);
|
||||
emit this->setupRequired();
|
||||
rep->deleteLater();
|
||||
});
|
||||
}
|
||||
|
||||
void ApiClient::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();
|
||||
});
|
||||
connect(rep, static_cast<void (QNetworkReply::*)(QNetworkReply::NetworkError)>(&QNetworkReply::error),
|
||||
this, [id, rep, this](QNetworkReply::NetworkError error) {
|
||||
emit this->itemFetchFailed(id, error);
|
||||
rep->deleteLater();
|
||||
});
|
||||
}
|
||||
|
||||
void ApiClient::postCapabilities() {
|
||||
QJsonObject capabilities;
|
||||
capabilities["SupportsPersistentIdentifier"] = false; // Technically untrue, but not implemented yet.
|
||||
capabilities["SupportsMediaControl"] = false;
|
||||
capabilities["SupportsSync"] = false;
|
||||
capabilities["SupportsContentUploading"] = false;
|
||||
capabilities["AppStoreUrl"] = "https://chris.netsoj.nl/projects/harbour-sailfin";
|
||||
capabilities["IconUrl"] = "https://chris.netsoj.nl/static/img/logo.png";
|
||||
capabilities["DeviceProfile"] = m_deviceProfile;
|
||||
QNetworkReply *rep = post("/Sessions/Capabilities/Full", QJsonDocument(capabilities));
|
||||
setDefaultErrorHandler(rep);
|
||||
}
|
||||
|
||||
void ApiClient::generateDeviceProfile() {
|
||||
QJsonObject root = DeviceProfile::generateProfile();
|
||||
m_playbackDeviceProfile = QJsonObject(root);
|
||||
root["Name"] = m_deviceName;
|
||||
root["Id"] = m_deviceId;
|
||||
root["FriendlyName"] = QSysInfo::prettyProductName();
|
||||
QJsonArray playableMediaTypes;
|
||||
playableMediaTypes.append("Audio");
|
||||
playableMediaTypes.append("Video");
|
||||
playableMediaTypes.append("Photo");
|
||||
root["PlayableMediaTypes"] = playableMediaTypes;
|
||||
|
||||
m_deviceProfile = root;
|
||||
}
|
||||
|
||||
void ApiClient::defaultNetworkErrorHandler(QNetworkReply::NetworkError error) {
|
||||
QObject *signalSender = sender();
|
||||
QNetworkReply *rep = dynamic_cast<QNetworkReply *>(signalSender);
|
||||
if (rep != nullptr && statusCode(rep) == 401) {
|
||||
this->setAuthenticated(false);
|
||||
emit this->authenticationError(ApiError::INVALID_PASSWORD);
|
||||
} else {
|
||||
emit this->networkError(error);
|
||||
}
|
||||
rep->deleteLater();
|
||||
}
|
||||
|
||||
void ApiClient::setAuthenticated(bool authenticated) {
|
||||
this->m_authenticated = authenticated;
|
||||
if (authenticated) m_webSocket->open();
|
||||
emit authenticatedChanged(authenticated);
|
||||
}
|
||||
}
|
223
core/src/jellyfinapimodel.cpp
Normal file
223
core/src/jellyfinapimodel.cpp
Normal file
|
@ -0,0 +1,223 @@
|
|||
/*
|
||||
Sailfin: a Jellyfin client written using Qt
|
||||
Copyright (C) 2020 Chris Josten
|
||||
|
||||
This library is free software; you can redistribute it and/or
|
||||
modify it under the terms of the GNU Lesser General Public
|
||||
License as published by the Free Software Foundation; either
|
||||
version 2.1 of the License, or (at your option) any later version.
|
||||
|
||||
This library is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
Lesser General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Lesser General Public
|
||||
License along with this library; if not, write to the Free Software
|
||||
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
*/
|
||||
|
||||
#include "jellyfinapimodel.h"
|
||||
|
||||
namespace Jellyfin {
|
||||
ApiModel::ApiModel(QString path, bool hasRecordResponse, bool addUserId, QObject *parent)
|
||||
: QAbstractListModel (parent),
|
||||
m_path(path),
|
||||
m_hasRecordResponse(hasRecordResponse),
|
||||
m_addUserId(addUserId){
|
||||
}
|
||||
|
||||
void ApiModel::reload() {
|
||||
this->setStatus(Loading);
|
||||
m_startIndex = 0;
|
||||
load(RELOAD);
|
||||
}
|
||||
|
||||
void ApiModel::load(LoadType type) {
|
||||
qDebug() << (type == RELOAD ? "RELOAD" : "LOAD_MORE");
|
||||
if (m_apiClient == nullptr) {
|
||||
qWarning() << "Please set the apiClient property before (re)loading";
|
||||
return;
|
||||
}
|
||||
if (m_path.contains("{{user}}")) {
|
||||
m_path = m_path.replace("{{user}}", m_apiClient->userId());
|
||||
}
|
||||
if (m_path.contains("{{show}}") && !m_show.isEmpty()) {
|
||||
m_path = m_path.replace("{{show}}", m_show);
|
||||
}
|
||||
QUrlQuery query;
|
||||
if (m_limit >= 0) {
|
||||
query.addQueryItem("Limit", QString::number(m_limit));
|
||||
} else {
|
||||
query.addQueryItem("Limit", QString::number(DEFAULT_LIMIT));
|
||||
}
|
||||
if (m_startIndex > 0) {
|
||||
query.addQueryItem("StartIndex", QString::number(m_startIndex));
|
||||
}
|
||||
if (!m_parentId.isEmpty()) {
|
||||
query.addQueryItem("ParentId", m_parentId);
|
||||
}
|
||||
if (!m_sortBy.empty()) {
|
||||
query.addQueryItem("SortBy", m_sortBy.join(","));
|
||||
}
|
||||
if (m_sortOrder != Unspecified) {
|
||||
query.addQueryItem("SortOrder", m_sortOrder == Ascending ? "Ascending" : "Descending");
|
||||
}
|
||||
if (!m_imageTypes.empty()) {
|
||||
query.addQueryItem("ImageTypes", m_imageTypes.join(","));
|
||||
}
|
||||
if (!m_fields.empty()) {
|
||||
query.addQueryItem("Fields", m_fields.join(","));
|
||||
}
|
||||
if (!m_seasonId.isEmpty()) {
|
||||
query.addQueryItem("seasonId", m_seasonId);
|
||||
}
|
||||
if (m_addUserId) {
|
||||
query.addQueryItem("userId", m_apiClient->userId());
|
||||
}
|
||||
if (m_recursive) {
|
||||
query.addQueryItem("Recursive", "true");
|
||||
}
|
||||
addQueryParameters(query);
|
||||
QNetworkReply *rep = m_apiClient->get(m_path, query);
|
||||
connect(rep, &QNetworkReply::finished, this, [this, type, rep]() {
|
||||
QJsonDocument doc = QJsonDocument::fromJson(rep->readAll());
|
||||
if (!m_hasRecordResponse) {
|
||||
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("Items")) {
|
||||
qWarning() << "Object doesn't contain items!";
|
||||
this->setStatus(Error);
|
||||
return;
|
||||
}
|
||||
if (m_limit < 0) {
|
||||
// Javascript is beautiful
|
||||
if (obj.contains("TotalRecordCount") && obj["TotalRecordCount"].isDouble()) {
|
||||
m_totalRecordCount = obj["TotalRecordCount"].toInt();
|
||||
m_startIndex += DEFAULT_LIMIT;
|
||||
} else {
|
||||
qWarning() << "Record-response does not have a total record count";
|
||||
this->setStatus(Error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!obj["Items"].isArray()) {
|
||||
qWarning() << "Items is not an array!";
|
||||
this->setStatus(Error);
|
||||
return;
|
||||
}
|
||||
QJsonArray items = obj["Items"].toArray();
|
||||
switch(type) {
|
||||
case RELOAD:
|
||||
this->m_array = items;
|
||||
break;
|
||||
case LOAD_MORE:
|
||||
this->beginInsertRows(QModelIndex(), m_array.size(), m_array.size() + items.size() - 1);
|
||||
// QJsonArray apparently doesn't allow concatenating lists like QList or std::vector
|
||||
foreach (const QJsonValue &val, items) {
|
||||
m_array.append(val);
|
||||
}
|
||||
this->endInsertRows();
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (type == RELOAD) {
|
||||
generateFields();
|
||||
}
|
||||
this->setStatus(Ready);
|
||||
rep->deleteLater();
|
||||
});
|
||||
}
|
||||
|
||||
void ApiModel::generateFields() {
|
||||
if (m_array.size() == 0) return;
|
||||
this->beginResetModel();
|
||||
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();
|
||||
}
|
||||
|
||||
bool ApiModel::canFetchMore(const QModelIndex &parent) const {
|
||||
if (parent.isValid()) return false;
|
||||
switch(m_status) {
|
||||
case Uninitialised:
|
||||
case Loading:
|
||||
return false;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (m_limit < 0) {
|
||||
return m_startIndex <= m_totalRecordCount;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
void ApiModel::fetchMore(const QModelIndex &parent) {
|
||||
if (parent.isValid()) return;
|
||||
this->setStatus(LoadingMore);
|
||||
load(LOAD_MORE);
|
||||
}
|
||||
|
||||
void ApiModel::addQueryParameters(QUrlQuery &query) { Q_UNUSED(query)}
|
||||
|
||||
|
||||
void registerModels(const char *URI) {
|
||||
qmlRegisterUncreatableType<ApiModel>(URI, 1, 0, "ApiModel", "Is enum and base class");
|
||||
qmlRegisterUncreatableType<SortOptions>(URI, 1, 0, "SortOptions", "Is enum");
|
||||
qmlRegisterType<PublicUserModel>(URI, 1, 0, "PublicUserModel");
|
||||
qmlRegisterType<UserViewModel>(URI, 1, 0, "UserViewModel");
|
||||
qmlRegisterType<UserItemModel>(URI, 1, 0, "UserItemModel");
|
||||
qmlRegisterType<UserItemLatestModel>(URI, 1, 0, "UserItemLatestModel");
|
||||
qmlRegisterType<UserItemResumeModel>(URI, 1, 0, "UserItemResumeModel");
|
||||
qmlRegisterType<ShowSeasonsModel>(URI, 1, 0, "ShowSeasonsModel");
|
||||
qmlRegisterType<ShowEpisodesModel>(URI, 1, 0, "ShowEpisodesModel");
|
||||
}
|
||||
}
|
191
core/src/jellyfindeviceprofile.cpp
Normal file
191
core/src/jellyfindeviceprofile.cpp
Normal file
|
@ -0,0 +1,191 @@
|
|||
/*
|
||||
Sailfin: a Jellyfin client written using Qt
|
||||
Copyright (C) 2020 Chris Josten
|
||||
|
||||
This library is free software; you can redistribute it and/or
|
||||
modify it under the terms of the GNU Lesser General Public
|
||||
License as published by the Free Software Foundation; either
|
||||
version 2.1 of the License, or (at your option) any later version.
|
||||
|
||||
This library is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
Lesser General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Lesser General Public
|
||||
License along with this library; if not, write to the Free Software
|
||||
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
*/
|
||||
|
||||
#include "jellyfindeviceprofile.h"
|
||||
|
||||
namespace Jellyfin {
|
||||
|
||||
bool DeviceProfile::supportsHls() {
|
||||
return true;
|
||||
}
|
||||
|
||||
bool DeviceProfile::canPlayH264() {
|
||||
return true;
|
||||
}
|
||||
|
||||
bool DeviceProfile::canPlayAc3() {
|
||||
return true;
|
||||
}
|
||||
|
||||
bool DeviceProfile::supportsMp3VideoAudio() {
|
||||
qDebug() << "Mp3VideoAudio: " << QMediaPlayer::hasSupport("video/mp4", {"avc1.640029", "mp3"}, QMediaPlayer::StreamPlayback);
|
||||
return true;
|
||||
}
|
||||
|
||||
int DeviceProfile::maxStreamingBitrate() {
|
||||
return 5000000;
|
||||
}
|
||||
|
||||
QJsonObject DeviceProfile::generateProfile() {
|
||||
using JsonPair = QPair<QString, QJsonValue>;
|
||||
QJsonObject profile;
|
||||
|
||||
QStringList videoAudioCodecs;
|
||||
QStringList mp4VideoCodecs;
|
||||
QStringList hlsVideoCodecs;
|
||||
QStringList hlsVideoAudioCodecs;
|
||||
|
||||
if (canPlayH264()) {
|
||||
mp4VideoCodecs.append("h264");
|
||||
hlsVideoCodecs.append("h264");
|
||||
}
|
||||
|
||||
if (canPlayAc3()) {
|
||||
videoAudioCodecs.append("ac3");
|
||||
hlsVideoAudioCodecs.append("ac3");
|
||||
}
|
||||
|
||||
if (supportsMp3VideoAudio()) {
|
||||
videoAudioCodecs.append("mp3");
|
||||
hlsVideoAudioCodecs.append("mp3");
|
||||
}
|
||||
|
||||
QJsonArray codecProfiles = {};
|
||||
codecProfiles.append(QJsonObject {
|
||||
JsonPair("Codec", "aac"),
|
||||
JsonPair("Conditions", QJsonArray {
|
||||
QJsonObject {
|
||||
JsonPair("Property", "IsSecondaryAudio"),
|
||||
JsonPair("Condition", "Equals"),
|
||||
JsonPair("Value", false),
|
||||
JsonPair("IsRequired", false)
|
||||
}
|
||||
}),
|
||||
JsonPair("Type", "VideoAudio")
|
||||
});
|
||||
codecProfiles.append(QJsonObject {
|
||||
JsonPair("Coded", "h264"),
|
||||
JsonPair("Conditions", QJsonArray {
|
||||
QJsonObject {
|
||||
JsonPair("Property", "IsAnamorphic"),
|
||||
JsonPair("Condition", "NotEquals"),
|
||||
JsonPair("Value", true),
|
||||
JsonPair("IsRequired", false)
|
||||
},
|
||||
QJsonObject {
|
||||
JsonPair("Property", "VideoProfile"),
|
||||
JsonPair("Condition", "EqualsAny"),
|
||||
JsonPair("Value", "baseline|constrained baseline"), //"high|main|baseline|constrained baseline"),
|
||||
JsonPair("IsRequired", false),
|
||||
},
|
||||
QJsonObject {
|
||||
JsonPair("Property", "VideoLevel"),
|
||||
JsonPair("Condition", "LessThanEqual"),
|
||||
JsonPair("Value", 51),
|
||||
JsonPair("IsRequired", false)
|
||||
},
|
||||
QJsonObject {
|
||||
JsonPair("Property", "IsInterlaced"),
|
||||
JsonPair("Condition", "NotEquals"),
|
||||
JsonPair("Value", true),
|
||||
JsonPair("IsRequired", false)
|
||||
}
|
||||
}),
|
||||
JsonPair("Type", "Video")
|
||||
});
|
||||
|
||||
QJsonArray transcodingProfiles = {};
|
||||
|
||||
// Hard coded nr 1:
|
||||
QJsonObject transcoding1;
|
||||
transcoding1["AudioCodec"] = "aac";
|
||||
transcoding1["BreakOnNonKeyFrames"] =true;
|
||||
transcoding1["Container"] = "ts";
|
||||
transcoding1["Context"] = "Streaming";
|
||||
transcoding1["MaxAudioChannels"] = 2;
|
||||
transcoding1["MinSegments"] = 1;
|
||||
transcoding1["Protocol"] = "hls";
|
||||
transcoding1["Type"] = "Audio";
|
||||
transcodingProfiles.append(transcoding1);
|
||||
|
||||
// Hard code nr 2
|
||||
transcodingProfiles.append(QJsonObject({
|
||||
JsonPair("AudioCodec", "mp3,aac"),
|
||||
JsonPair("BreakOnNonKeyFrames", true),
|
||||
JsonPair("Container", "ts"),
|
||||
JsonPair("Context", "Streaming"),
|
||||
JsonPair("MaxAudioChannels", 2),
|
||||
JsonPair("MinSegments", 1),
|
||||
JsonPair("Protocol", "hls"),
|
||||
JsonPair("Type", "Video"),
|
||||
JsonPair("VideoCodec", "h264")
|
||||
}));
|
||||
|
||||
// Fallback
|
||||
transcodingProfiles.append(QJsonObject {
|
||||
JsonPair("Container", "mp4"),
|
||||
JsonPair("Type", "Video"),
|
||||
JsonPair("AudioCodec", videoAudioCodecs.join(',')),
|
||||
JsonPair("VideoCodec", "h264"),
|
||||
JsonPair("Context", "Static"),
|
||||
JsonPair("Protocol", "http")
|
||||
});
|
||||
|
||||
|
||||
if (supportsHls() && !hlsVideoAudioCodecs.isEmpty()) {
|
||||
transcodingProfiles.append(QJsonObject {
|
||||
JsonPair("Container", "ts"),
|
||||
JsonPair("Type", "Video"),
|
||||
JsonPair("AudioCodec", hlsVideoAudioCodecs.join(",")),
|
||||
JsonPair("VideoCodec", hlsVideoCodecs.join(",")),
|
||||
JsonPair("Context", "Streaming"),
|
||||
JsonPair("Protocol", "hls"),
|
||||
JsonPair("MaxAudioChannels", 2),
|
||||
JsonPair("MinSegments", 1),
|
||||
JsonPair("BreakOnNonKeyFrames", true)
|
||||
});
|
||||
}
|
||||
|
||||
// Response profiles (or whatever it actually does?)
|
||||
QJsonArray responseProfiles = {};
|
||||
responseProfiles.append(QJsonObject({
|
||||
JsonPair("Type", "Video"),
|
||||
JsonPair("Container", "m4v"),
|
||||
JsonPair("MimeType", "video/mp4")
|
||||
}));
|
||||
|
||||
// Direct play profiles
|
||||
QJsonArray directPlayProfiles;
|
||||
directPlayProfiles.append(QJsonObject {
|
||||
JsonPair("Container", "mp4,m4v"),
|
||||
JsonPair("Type", "Video"),
|
||||
JsonPair("VideoCodec", mp4VideoCodecs.join(',')),
|
||||
JsonPair("AudioCodec", videoAudioCodecs.join(','))
|
||||
});
|
||||
|
||||
profile["CodecProfiles"] = codecProfiles;
|
||||
profile["ContainerProfiles"] = QJsonArray();
|
||||
profile["DirectPlayProfiles"] = directPlayProfiles;
|
||||
profile["ResponseProfiles"] = responseProfiles;
|
||||
profile["SubtitleProfiles"] = QJsonArray();
|
||||
profile["TranscodingProfiles"] = transcodingProfiles;
|
||||
profile["MaxStreamingBitrate"] = maxStreamingBitrate();
|
||||
return profile;
|
||||
}
|
||||
}
|
263
core/src/jellyfinitem.cpp
Normal file
263
core/src/jellyfinitem.cpp
Normal file
|
@ -0,0 +1,263 @@
|
|||
#include "jellyfinitem.h"
|
||||
|
||||
namespace Jellyfin {
|
||||
const QRegularExpression JsonSerializable::m_listExpression = QRegularExpression("^QList<\\s*([a-zA-Z0-9]*)\\s*\\*?\\s*>$");
|
||||
JsonSerializable::JsonSerializable(QObject *parent) : QObject(parent) {
|
||||
}
|
||||
|
||||
void JsonSerializable::deserialize(const QJsonObject &jObj) {
|
||||
const QMetaObject *obj = this->metaObject();
|
||||
|
||||
// Loop over each property,
|
||||
for (int i = 0; i < obj->propertyCount(); i++) {
|
||||
QMetaProperty prop = obj->property(i);
|
||||
// Skip properties which are not stored (usually derrived of other properties)
|
||||
if (!prop.isStored()) continue;
|
||||
if (!prop.isWritable()) continue;
|
||||
|
||||
qDebug() << toPascalCase(prop.name());
|
||||
// Hardcoded exception for the property id, since its special inside QML
|
||||
if (QString(prop.name()) == "jellyfinId" && jObj.contains("Id")) {
|
||||
QJsonValue val = jObj["Id"];
|
||||
prop.write(this, jsonToVariant(prop, val, jObj));
|
||||
} else if (jObj.contains(toPascalCase(prop.name()))) {
|
||||
QJsonValue val = jObj[toPascalCase(prop.name())];
|
||||
prop.write(this, jsonToVariant(prop, val, jObj));
|
||||
} else if (QString(prop.name()).startsWith("__list__")) {
|
||||
// QML doesn't like it if we expose properties of type QList<SubclassOfQobject *>
|
||||
// we need to explicitly cast them to QList<QObject *> or QQmlListProperty, which sucks.
|
||||
// That's why we have a special case for properties starting with __list__. This contains
|
||||
// the actual QList<SubclassOfQobject *>, so that qml can access the object with its real name.
|
||||
QString realName = toPascalCase(prop.name() + 8);
|
||||
if (!jObj.contains(realName)) {
|
||||
qDebug() << "Ignoring " << realName << " - " << prop.name();
|
||||
continue;
|
||||
}
|
||||
QJsonValue val = jObj[realName];
|
||||
qDebug() << realName << " - " << prop.name() << ": " << val;
|
||||
QMetaProperty realProp = obj->property(obj->indexOfProperty(prop.name() + 8));
|
||||
if (!realProp.write(this, jsonToVariant(prop, val, jObj))) {
|
||||
qDebug() << "Write to " << prop.name() << "failed";
|
||||
};
|
||||
} else {
|
||||
qDebug() << "Ignored " << prop.name() << " while deserializing";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
QVariant JsonSerializable::jsonToVariant(QMetaProperty prop, const QJsonValue &val, const QJsonObject &root) {
|
||||
switch(val.type()) {
|
||||
case QJsonValue::Null:
|
||||
case QJsonValue::Undefined:
|
||||
return QVariant();
|
||||
case QJsonValue::Bool:
|
||||
case QJsonValue::Double:
|
||||
case QJsonValue::String:
|
||||
return val.toVariant();
|
||||
case QJsonValue::Array:
|
||||
{
|
||||
QJsonArray arr = val.toArray();
|
||||
QVariantList varArr;
|
||||
for (auto it = arr.constBegin(); it < arr.constEnd(); it++) {
|
||||
QVariant variant = jsonToVariant(prop, *it, root);
|
||||
qDebug() << variant;
|
||||
varArr.append(variant);
|
||||
}
|
||||
qDebug() << prop.name() << ": " << varArr.count();
|
||||
return QVariant(varArr);
|
||||
}
|
||||
case QJsonValue::Object:
|
||||
QJsonObject innerObj = val.toObject();
|
||||
int typeNo = prop.userType();
|
||||
const QMetaObject *metaType = QMetaType::metaObjectForType(prop.userType());
|
||||
if (metaType == nullptr) {
|
||||
// Try to determine if the type is a qlist
|
||||
QRegularExpressionMatch match = m_listExpression.match(prop.typeName());
|
||||
if (match.hasMatch()) {
|
||||
// It is a qList! Now extract the inner type
|
||||
// There should be an easier way, shouldn't there?
|
||||
QString listType = match.captured(1).prepend("Jellyfin::").append("*");
|
||||
// UGLY CODE HERE WE COME
|
||||
typeNo = QMetaType::type(listType.toUtf8());
|
||||
if (typeNo == QMetaType::UnknownType) {
|
||||
qDebug() << "Unknown type: " << listType;
|
||||
return QVariant();
|
||||
}
|
||||
metaType = QMetaType::metaObjectForType(typeNo);
|
||||
} else {
|
||||
qDebug() << "No metaObject for " << prop.typeName() << ", " << prop.type() << ", " << prop.userType();
|
||||
return QVariant();
|
||||
}
|
||||
}
|
||||
QObject *deserializedInnerObj = metaType->newInstance();
|
||||
deserializedInnerObj->setParent(this);
|
||||
if (JsonSerializable *ser = dynamic_cast<JsonSerializable *>(deserializedInnerObj)) {
|
||||
qDebug() << "Deserializing user type " << deserializedInnerObj->metaObject()->className();
|
||||
ser->deserialize(innerObj);
|
||||
return QVariant(typeNo, &ser);
|
||||
} else {
|
||||
deserializedInnerObj->deleteLater();
|
||||
qDebug() << "Object is not a serializable one!";
|
||||
return QVariant();
|
||||
}
|
||||
}
|
||||
return QVariant();
|
||||
}
|
||||
|
||||
QJsonObject JsonSerializable::serialize() const {
|
||||
QJsonObject result;
|
||||
const QMetaObject *obj = this->metaObject();
|
||||
for (int i = 0; i < obj->propertyCount(); i++) {
|
||||
QMetaProperty prop = obj->property(i);
|
||||
if (QString(prop.name()) == "jellyfinId") {
|
||||
result["Id"] = variantToJson(prop.read(this));
|
||||
} else {
|
||||
result[toPascalCase(prop.name())] = variantToJson(prop.read(this));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
QJsonValue JsonSerializable::variantToJson(const QVariant var) const {
|
||||
switch(var.type()) {
|
||||
case QVariant::Invalid:
|
||||
return QJsonValue();
|
||||
case QVariant::UserType:
|
||||
if (var.canConvert<JsonSerializable *>()) {
|
||||
JsonSerializable * obj = var.value<JsonSerializable *>();
|
||||
return obj->serialize();
|
||||
} else {
|
||||
qWarning() << "Not serializable: " << var.typeName();
|
||||
return QJsonValue();
|
||||
}
|
||||
case QVariant::Bool:
|
||||
return var.toBool();
|
||||
case QVariant::List:
|
||||
{
|
||||
QVariantList list = var.toList();
|
||||
QJsonArray arr;
|
||||
for (auto it = list.begin(); it < list.end(); it++) {
|
||||
arr << variantToJson(*it);
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
default:
|
||||
if (var.canConvert(QVariant::Double)) {
|
||||
return var.toDouble();
|
||||
} if (var.canConvert(QVariant::String)) {
|
||||
return var.toString();
|
||||
} else {
|
||||
return QJsonValue();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
QString JsonSerializable::toPascalCase(QString str) {
|
||||
str[0] = str[0].toUpper();
|
||||
return str;
|
||||
}
|
||||
|
||||
QString JsonSerializable::fromPascalCase(QString str) {
|
||||
str[0] = str[0].toLower();
|
||||
return str;
|
||||
}
|
||||
|
||||
|
||||
// RemoteData
|
||||
RemoteData::RemoteData(QObject *parent) : JsonSerializable (parent) {}
|
||||
|
||||
void RemoteData::setStatus(Status newStatus) {
|
||||
m_status = newStatus;
|
||||
emit statusChanged(newStatus);
|
||||
}
|
||||
|
||||
void RemoteData::setError(QNetworkReply::NetworkError error) {
|
||||
m_error = error;
|
||||
emit errorChanged(error);
|
||||
}
|
||||
|
||||
void RemoteData::setErrorString(const QString &newErrorString) {
|
||||
m_errorString = newErrorString;
|
||||
emit errorStringChanged(newErrorString);
|
||||
}
|
||||
|
||||
void RemoteData::setApiClient(ApiClient *newApiClient) {
|
||||
m_apiClient = newApiClient;
|
||||
emit apiClientChanged(newApiClient);
|
||||
reload();
|
||||
}
|
||||
|
||||
// MediaStream
|
||||
MediaStream::MediaStream(QObject *parent) : JsonSerializable (parent) {}
|
||||
MediaStream::MediaStream(const MediaStream &other)
|
||||
: JsonSerializable (other.parent()),
|
||||
m_codec(other.m_codec),
|
||||
m_codecTag(other.m_codecTag),
|
||||
m_language(other.m_language),
|
||||
m_displayTitle(other.m_displayTitle),
|
||||
m_type(other.m_type),
|
||||
m_index(other.m_index){
|
||||
}
|
||||
bool MediaStream::operator==(const MediaStream &other) {
|
||||
// displayTitle is explicitly left out, since it's generated based on other properties
|
||||
// in the Jellyfin source code.
|
||||
return m_codec == other.m_codec && m_codecTag == other.m_codecTag
|
||||
&& m_language == other.m_language && m_type == other.m_type
|
||||
&& m_index == other.m_index;
|
||||
}
|
||||
|
||||
// Item
|
||||
|
||||
Item::Item(QObject *parent) : RemoteData(parent) {}
|
||||
|
||||
|
||||
void Item::setJellyfinId(QString newId) {
|
||||
m_id = newId.trimmed();
|
||||
if (m_id != newId) {
|
||||
emit jellyfinIdChanged(m_id);
|
||||
reload();
|
||||
}
|
||||
}
|
||||
|
||||
void Item::reload() {
|
||||
if (m_id.isEmpty() || m_apiClient == nullptr) {
|
||||
setStatus(Uninitialised);
|
||||
return;
|
||||
} else {
|
||||
setStatus(Loading);
|
||||
}
|
||||
QNetworkReply *rep = m_apiClient->get("/Users/" + m_apiClient->userId() + "/Items/" + m_id);
|
||||
connect(rep, &QNetworkReply::finished, this, [this, rep]() {
|
||||
rep->deleteLater();
|
||||
|
||||
QJsonParseError error;
|
||||
QString data(rep->readAll());
|
||||
data = data.normalized(QString::NormalizationForm_D);
|
||||
QJsonDocument doc = QJsonDocument::fromJson(data.toUtf8(), &error);
|
||||
if (doc.isNull()) {
|
||||
this->setError(QNetworkReply::ProtocolFailure);
|
||||
this->setErrorString(error.errorString());
|
||||
return;
|
||||
}
|
||||
if (!doc.isObject()) {
|
||||
this->setError(QNetworkReply::ProtocolFailure);
|
||||
this->setErrorString(tr("Invalid response from the server: root element is not an object."));
|
||||
return;
|
||||
}
|
||||
this->deserialize(doc.object());
|
||||
this->setStatus(Ready);
|
||||
});
|
||||
connect(rep, static_cast<void (QNetworkReply::*)(QNetworkReply::NetworkError)>(&QNetworkReply::error),
|
||||
this, [this, rep](QNetworkReply::NetworkError error) {
|
||||
rep->deleteLater();
|
||||
this->setError(error);
|
||||
this->setErrorString(rep->errorString());
|
||||
this->setStatus(Error);
|
||||
});
|
||||
}
|
||||
|
||||
void registerSerializableJsonTypes(const char* URI) {
|
||||
qmlRegisterType<MediaStream>(URI, 1, 0, "MediaStream");
|
||||
qmlRegisterType<Item>(URI, 1, 0, "JellyfinItem");
|
||||
}
|
||||
}
|
163
core/src/jellyfinplaybackmanager.cpp
Normal file
163
core/src/jellyfinplaybackmanager.cpp
Normal file
|
@ -0,0 +1,163 @@
|
|||
/*
|
||||
Sailfin: a Jellyfin client written using Qt
|
||||
Copyright (C) 2020 Chris Josten
|
||||
|
||||
This library is free software; you can redistribute it and/or
|
||||
modify it under the terms of the GNU Lesser General Public
|
||||
License as published by the Free Software Foundation; either
|
||||
version 2.1 of the License, or (at your option) any later version.
|
||||
|
||||
This library is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
Lesser General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Lesser General Public
|
||||
License along with this library; if not, write to the Free Software
|
||||
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
*/
|
||||
|
||||
#include "jellyfinplaybackmanager.h"
|
||||
|
||||
namespace Jellyfin {
|
||||
|
||||
PlaybackManager::PlaybackManager(QObject *parent)
|
||||
: QObject(parent) {
|
||||
m_updateTimer.setInterval(10000); // 10 seconds
|
||||
m_updateTimer.setSingleShot(false);
|
||||
connect(&m_updateTimer, &QTimer::timeout, this, &PlaybackManager::updatePlaybackInfo);
|
||||
}
|
||||
|
||||
void PlaybackManager::fetchStreamUrl() {
|
||||
QUrlQuery params;
|
||||
params.addQueryItem("UserId", m_apiClient->userId());
|
||||
params.addQueryItem("StartTimeTicks", QString::number(m_position));
|
||||
params.addQueryItem("IsPlayback", "true");
|
||||
params.addQueryItem("AutoOpenLiveStream", this->m_autoOpen ? "true" : "false");
|
||||
params.addQueryItem("MediaSourceId", this->m_itemId);
|
||||
params.addQueryItem("SubtitleStreamIndex", QString::number(m_subtitleIndex));
|
||||
params.addQueryItem("AudioStreamIndex", QString::number(m_audioIndex));
|
||||
|
||||
QJsonObject root;
|
||||
root["DeviceProfile"] = m_apiClient->playbackDeviceProfile();
|
||||
|
||||
QNetworkReply *rep = m_apiClient->post("/Items/" + this->m_itemId + "/PlaybackInfo", QJsonDocument(root), params);
|
||||
connect(rep, &QNetworkReply::finished, this, [this, rep]() {
|
||||
QJsonObject root = QJsonDocument::fromJson(rep->readAll()).object();
|
||||
this->m_playSessionId = root["PlaySessionId"].toString();
|
||||
qDebug() << "Session id: " << this->m_playSessionId;
|
||||
|
||||
if (this->m_autoOpen) {
|
||||
QJsonArray mediaSources = root["MediaSources"].toArray();
|
||||
//FIXME: relies on the fact that the returned transcode url always has a query!
|
||||
this->m_streamUrl = this->m_apiClient->baseUrl()
|
||||
+ mediaSources[0].toObject()["TranscodingUrl"].toString();
|
||||
|
||||
this->m_playMethod = Transcode;
|
||||
emit this->streamUrlChanged(this->m_streamUrl);
|
||||
qDebug() << "Found stream url: " << this->m_streamUrl;
|
||||
}
|
||||
|
||||
rep->deleteLater();
|
||||
});
|
||||
}
|
||||
|
||||
void PlaybackManager::setItemId(const QString &newItemId) {
|
||||
if (m_apiClient == nullptr) {
|
||||
qWarning() << "apiClient is not set on this MediaSource instance! Aborting.";
|
||||
return;
|
||||
}
|
||||
|
||||
this->m_itemId = newItemId;
|
||||
// Deinitialize the streamUrl
|
||||
setStreamUrl("");
|
||||
if (!newItemId.isEmpty()) {
|
||||
fetchStreamUrl();
|
||||
}
|
||||
}
|
||||
|
||||
void PlaybackManager::setStreamUrl(const QString &streamUrl) {
|
||||
this->m_streamUrl = streamUrl;
|
||||
emit streamUrlChanged(streamUrl);
|
||||
}
|
||||
|
||||
void PlaybackManager::setPosition(qint64 position) {
|
||||
if (position == 0 && m_position != 0) {
|
||||
// Save the old position when stop gets called. The QMediaPlayer will try to set
|
||||
// position to 0 when stopped, but we don't want to report that to Jellyfin. We
|
||||
// want the old position.
|
||||
m_stopPosition = m_position;
|
||||
}
|
||||
m_position = position;
|
||||
emit positionChanged(position);
|
||||
}
|
||||
|
||||
void PlaybackManager::setState(QMediaPlayer::State newState) {
|
||||
if (m_state == newState) return;
|
||||
if (m_state == QMediaPlayer::StoppedState) {
|
||||
// We're transitioning from stopped to either playing or paused.
|
||||
// Set up the recurring timer
|
||||
m_updateTimer.start();
|
||||
postPlaybackInfo(Started);
|
||||
} else if (newState == QMediaPlayer::StoppedState) {
|
||||
// We've stopped playing the media. Post a stop signal.
|
||||
m_updateTimer.stop();
|
||||
postPlaybackInfo(Stopped);
|
||||
} else {
|
||||
postPlaybackInfo(Progress);
|
||||
}
|
||||
|
||||
|
||||
m_state = newState;
|
||||
emit this->stateChanged(newState);
|
||||
}
|
||||
|
||||
void PlaybackManager::updatePlaybackInfo() {
|
||||
postPlaybackInfo(Progress);
|
||||
}
|
||||
|
||||
void PlaybackManager::postPlaybackInfo(PlaybackInfoType type) {
|
||||
QJsonObject root;
|
||||
|
||||
root["ItemId"] = m_itemId;
|
||||
root["SessionId"] = m_playSessionId;
|
||||
|
||||
switch(type) {
|
||||
case Started: // FALLTHROUGH
|
||||
case Progress:
|
||||
|
||||
root["IsPaused"] = m_state != QMediaPlayer::PlayingState;
|
||||
root["IsMuted"] = false;
|
||||
|
||||
root["AudioStreamIndex"] = m_audioIndex;
|
||||
root["SubtitleStreamIndex"] = m_subtitleIndex;
|
||||
|
||||
root["PlayMethod"] = QVariant::fromValue(m_playMethod).toString();
|
||||
root["PositionTicks"] = m_position;
|
||||
break;
|
||||
case Stopped:
|
||||
root["PositionTicks"] = m_stopPosition;
|
||||
break;
|
||||
}
|
||||
|
||||
QString path;
|
||||
switch (type) {
|
||||
case Started:
|
||||
path = "/Sessions/Playing";
|
||||
break;
|
||||
case Progress:
|
||||
path = "/Sessions/Playing/Progress";
|
||||
break;
|
||||
case Stopped:
|
||||
path = "/Sessions/Playing/Stopped";
|
||||
break;
|
||||
}
|
||||
|
||||
QNetworkReply *rep = m_apiClient->post(path, QJsonDocument(root));
|
||||
connect(rep, &QNetworkReply::finished, this, [rep](){
|
||||
rep->deleteLater();
|
||||
});
|
||||
m_apiClient->setDefaultErrorHandler(rep);
|
||||
}
|
||||
|
||||
}
|
114
core/src/jellyfinwebsocket.cpp
Normal file
114
core/src/jellyfinwebsocket.cpp
Normal file
|
@ -0,0 +1,114 @@
|
|||
/*
|
||||
Sailfin: a Jellyfin client written using Qt
|
||||
Copyright (C) 2020 Chris Josten
|
||||
|
||||
This library is free software; you can redistribute it and/or
|
||||
modify it under the terms of the GNU Lesser General Public
|
||||
License as published by the Free Software Foundation; either
|
||||
version 2.1 of the License, or (at your option) any later version.
|
||||
|
||||
This library is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
Lesser General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Lesser General Public
|
||||
License along with this library; if not, write to the Free Software
|
||||
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
*/
|
||||
#include "jellyfinwebsocket.h"
|
||||
|
||||
namespace Jellyfin {
|
||||
WebSocket::WebSocket(ApiClient *client)
|
||||
: QObject (client), m_apiClient(client){
|
||||
connect(&m_webSocket, &QWebSocket::connected, this, &WebSocket::onConnected);
|
||||
connect(&m_webSocket, &QWebSocket::disconnected, this, &WebSocket::onDisconnected);
|
||||
connect(&m_webSocket, static_cast<void (QWebSocket::*)(QAbstractSocket::SocketError)>(&QWebSocket::error),
|
||||
this, [this](QAbstractSocket::SocketError error) {
|
||||
Q_UNUSED(error)
|
||||
qDebug() << "Connection error: " << m_webSocket.errorString();
|
||||
});
|
||||
}
|
||||
|
||||
void WebSocket::open() {
|
||||
QUrlQuery query;
|
||||
query.addQueryItem("api_key", m_apiClient->token());
|
||||
query.addQueryItem("deviceId", m_apiClient->m_deviceId);
|
||||
QUrl connectionUrl(m_apiClient->baseUrl());
|
||||
connectionUrl.setScheme(connectionUrl.scheme() == "http" ? "ws" : "wss");
|
||||
connectionUrl.setPath("/socket");
|
||||
connectionUrl.setQuery(query);
|
||||
m_webSocket.open(connectionUrl);
|
||||
qDebug() << "Opening WebSocket connection to " << m_webSocket.requestUrl();
|
||||
}
|
||||
|
||||
void WebSocket::onConnected() {
|
||||
connect(&m_webSocket, &QWebSocket::textMessageReceived, this, &WebSocket::textMessageReceived);
|
||||
}
|
||||
|
||||
void WebSocket::onDisconnected() {
|
||||
disconnect(&m_webSocket, &QWebSocket::textMessageReceived, this, &WebSocket::textMessageReceived);
|
||||
m_keepAliveTimer.stop();
|
||||
}
|
||||
|
||||
void WebSocket::textMessageReceived(const QString &message) {
|
||||
QJsonDocument doc = QJsonDocument::fromJson(message.toUtf8());
|
||||
if (doc.isNull() || !doc.isObject()) {
|
||||
qWarning() << "Malformed message received over WebSocket: parse error or root not an object.";
|
||||
return;
|
||||
}
|
||||
QJsonObject messageRoot = doc.object();
|
||||
if (!messageRoot.contains("MessageType") || !messageRoot.contains("Data")) {
|
||||
qWarning() << "Malformed message received over WebSocket: no MessageType and Data set.";
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert the type so we can use it in our enums.
|
||||
QString messageTypeStr = messageRoot["MessageType"].toString();
|
||||
bool ok;
|
||||
MessageType messageType = static_cast<MessageType>(QMetaEnum::fromType<WebSocket::MessageType>().keyToValue(messageTypeStr.toLatin1(), &ok));
|
||||
if (!ok) {
|
||||
qWarning() << "Unknown message arrived: " << messageTypeStr;
|
||||
return;
|
||||
}
|
||||
|
||||
QJsonValue data = messageRoot["Data"];
|
||||
qDebug() << "Received message: " << messageTypeStr;
|
||||
|
||||
switch (messageType) {
|
||||
case ForceKeepAlive:
|
||||
setupKeepAlive(data.toInt(-1));
|
||||
break;
|
||||
case KeepAlive:
|
||||
//TODO: do something?
|
||||
break;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
void WebSocket::sendKeepAlive() {
|
||||
sendMessage(KeepAlive);
|
||||
}
|
||||
|
||||
void WebSocket::setupKeepAlive(int data) {
|
||||
// Data is timeout in seconds, we want to send a keepalive at half the timeout
|
||||
m_keepAliveTimer.setInterval(data * 500);
|
||||
m_keepAliveTimer.setSingleShot(false);
|
||||
connect(&m_keepAliveTimer, &QTimer::timeout, this, &WebSocket::sendKeepAlive);
|
||||
m_keepAliveTimer.start();
|
||||
sendKeepAlive();
|
||||
}
|
||||
|
||||
QString WebSocket::generateMessageId() {
|
||||
return QUuid::createUuid().toString();
|
||||
}
|
||||
|
||||
void WebSocket::sendMessage(MessageType type, QJsonValue data) {
|
||||
QJsonObject root;
|
||||
root["MessageType"] = QVariant::fromValue(type).toString();
|
||||
root["Data"] = data;
|
||||
QString message = QJsonDocument(root).toJson(QJsonDocument::Compact);
|
||||
m_webSocket.sendTextMessage(message);
|
||||
qDebug() << "Sent message: " << message;
|
||||
}
|
||||
}
|
93
core/src/serverdiscoverymodel.cpp
Normal file
93
core/src/serverdiscoverymodel.cpp
Normal file
|
@ -0,0 +1,93 @@
|
|||
/*
|
||||
Sailfin: a Jellyfin client written using Qt
|
||||
Copyright (C) 2020 Chris Josten
|
||||
|
||||
This library is free software; you can redistribute it and/or
|
||||
modify it under the terms of the GNU Lesser General Public
|
||||
License as published by the Free Software Foundation; either
|
||||
version 2.1 of the License, or (at your option) any later version.
|
||||
|
||||
This library is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
Lesser General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Lesser General Public
|
||||
License along with this library; if not, write to the Free Software
|
||||
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
*/
|
||||
#include "serverdiscoverymodel.h"
|
||||
|
||||
namespace Jellyfin {
|
||||
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();
|
||||
};
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue