/* Sailfin: a Jellyfin client written using Qt Copyright (C) 2021 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 #include #include #include #include #include #include #include #include #include #include #include #include #include "apiclient.h" #include "jsonhelper.h" #include "dto/baseitemdto.h" #include "dto/userdto.h" #include "dto/useritemdatadto.h" #include "dto/baseitemdtoqueryresult.h" #include "loader/requesttypes.h" #include "support/loader.h" #include "viewmodel/modelstatus.h" namespace Jellyfin { /* * A brief description of this file: * * The reason why all of this is this complex is because Qt MOC's lack of support for template classes * with Q_OBJECT. To work around this, "base classes", such as BaseModelLoader, are created which contain * all functionallity required by Q_OBJECT. This class is extended by the templated class, which in turn * is extended once more, so it can be registered for the QML engine. * * The loading of the data has beens separated from the QAbstractModels into ViewModel::Loaders * to allow for loading over the network, loading from the cache and so on, without the QAbstractModel * knowing anything about how it's done, except for the parameters it can pass to the loader and the result. * */ class BaseModelLoader : public QObject, public QQmlParserStatus { Q_INTERFACES(QQmlParserStatus) Q_OBJECT public: explicit BaseModelLoader(QObject *parent = nullptr); Q_PROPERTY(ApiClient *apiClient READ apiClient WRITE setApiClient NOTIFY apiClientChanged) Q_PROPERTY(Jellyfin::ViewModel::ModelStatusClass::Value status READ status NOTIFY statusChanged) Q_PROPERTY(int limit READ limit WRITE setLimit NOTIFY limitChanged) Q_PROPERTY(bool autoReload READ autoReload WRITE setAutoReload NOTIFY autoReloadChanged) ApiClient *apiClient() const { return m_apiClient; } void setApiClient(ApiClient *newApiClient); int limit() const { return m_limit; } void setLimit(int newLimit); bool autoReload() const { return m_autoReload; } void setAutoReload(bool newAutoReload); ViewModel::ModelStatus status() const { return m_status; } /** * @brief Clears and reloads the model */ Q_INVOKABLE virtual void reload() {}; // QQmlParserStatus interface virtual void classBegin() override; virtual void componentComplete() override; void autoReloadIfNeeded(); signals: void ready(); void apiClientChanged(ApiClient *newApiClient); void statusChanged(); void limitChanged(int newLimit); void autoReloadChanged(bool newAutoReload); /** * @brief Emitted when the model should clear itself */ void modelShouldClear(); /** * Emitted when new items are loaded. */ void itemsLoaded(); void reloadWanted(); public slots: virtual void futureReady() = 0; protected: // Is this object being parsed by the QML engine bool m_isBeingParsed = false; ApiClient *m_apiClient = nullptr; bool m_autoReload = true; bool m_needsAuthentication = true; // Query/record controlling properties int m_limit = -1; int m_startIndex = 0; int m_totalRecordCount = 0; const int DEFAULT_LIMIT = 100; void emitModelShouldClear() { emit modelShouldClear(); } void emitItemsLoaded() { emit itemsLoaded(); } ViewModel::ModelStatus m_status = ViewModel::ModelStatus::Uninitialised; void setStatus(ViewModel::ModelStatus newStatus) { if (this->m_status != newStatus) { this->m_status = newStatus; emit this->statusChanged(); if (this->m_status == ViewModel::ModelStatus::Ready) { emit ready(); } } } /** * @brief Determines if this model is able to reload. * * The default implementation checks if the user is authenticated, * and the model is not reloading. If overriding this method, please * call this method as well in determining if the model should be reloadable. * * @return True if the model can reload, false otherwise. */ virtual bool canReload() const; }; /** * Base model loader that only has one template parameter, * so it can be used within the ApiModel. */ template class ModelLoader : public BaseModelLoader { public: ModelLoader(QObject *parent = nullptr) : BaseModelLoader(parent) { } void reload() override { if (!canReload()) { qDebug() << "Cannot yet reload ApiModel: canReload() returned false."; return; } m_startIndex = 0; m_totalRecordCount = -1; emitModelShouldClear(); loadMore(0, m_limit, ViewModel::ModelStatus::Loading); } void loadMore() { if (!canReload()) { qDebug() << "Cannot yet reload ApiModel: canReload() returned false."; return; } loadMore(m_startIndex, m_limit, ViewModel::ModelStatus::LoadingMore); } virtual bool canLoadMore() const { return m_totalRecordCount == -1 || m_startIndex < m_totalRecordCount; } /** * @brief Holds the result. Moves it result to the caller and therefore can be only called once * when the itemsLoaded is emitted. * @return pair containing the items loaded and the integer containing the starting offset. A starting * offset of -1 means an error has occurred. */ std::pair, int> &&result() { return std::move(m_result); } protected: /** * @brief Loads data from the given offset with a maximum count of limit. * The itemsLoaded() signal is emitted when new data is ready. Call * getLoadedItems to retrieve the loaded items. * * @param offset The offset to start loading items from * @param limit The maximum amount of items to load. * @param suggestedStatus The suggested status this model should take on if it is able to load (more). * Either LOADING or LOAD_MORE. */ virtual void loadMore(int offset, int limit, ViewModel::ModelStatus suggestedStatus) = 0; void updatePosition(int startIndex, int totalRecordCount) { m_startIndex = startIndex; m_totalRecordCount = totalRecordCount; } std::pair, int> m_result; }; /** * Template to extract records from the given result. */ template QList extractRecords(const R &result) { Q_UNUSED(result) Q_UNIMPLEMENTED(); return QList(); } template int extractTotalRecordCount(const R &result) { Q_UNUSED(result) Q_UNIMPLEMENTED(); return -1; } template void setRequestLimit(R ¶meters, int limit) { Q_UNUSED(parameters) Q_UNUSED(limit) Q_UNIMPLEMENTED(); } /** * @return True if able to set the startIndex, false otherwise. */ template bool setRequestStartIndex(P ¶meters, int startIndex) { Q_UNUSED(parameters) Q_UNUSED(startIndex) Q_UNIMPLEMENTED(); return false; } #ifndef JELLYFIN_APIMODEL_CPP extern template bool setRequestStartIndex(Loader::GetUserViewsParams ¶ms, int startIndex); extern template void setRequestLimit(Loader::GetUserViewsParams ¶ms, int limit); extern template QList extractRecords(const DTO::BaseItemDtoQueryResult &result); extern template int extractTotalRecordCount(const DTO::BaseItemDtoQueryResult &result); extern template QList extractRecords(const QList &result); extern template int extractTotalRecordCount(const QList &result); extern template void setRequestLimit(Loader::GetLatestMediaParams ¶ms, int limit); extern template bool setRequestStartIndex(Loader::GetLatestMediaParams ¶ms, int offset); extern template QList extractRecords(const QList &result); extern template int extractTotalRecordCount(const QList &result); #endif /** * Template for implementing a loader for the given type, response and parameters using Jellyfin::Support:Loaders. * * @tparam T type of which this loader should load a list of * @tparam D type of the DTO which can be converted into T using T(const D&, ApiClient*); * @tparam R type of the deserialized loader response * @tparam P type of the deserialized loader request parameters */ template class LoaderModelLoader : public ModelLoader { public: explicit LoaderModelLoader(Support::Loader *loader, QObject *parent = nullptr) : ModelLoader(parent), m_loader(QScopedPointer>(loader)) { QObject::connect(&m_futureWatcher, &QFutureWatcher>::finished, this, &BaseModelLoader::futureReady); } protected: void loadMore(int offset, int limit, ViewModel::ModelStatus suggestedModelStatus) override { // This method should only be callable on one thread. // If futureWatcher's future is running, this method should not be called again. if (m_futureWatcher.isRunning()) return; // Set an invalid result. this->m_result = { QList(), -1 }; if (!setRequestStartIndex

(this->m_parameters, offset) && suggestedModelStatus == ViewModel::ModelStatus::LoadingMore) { // This loader's parameters does not setting a starting index, // meaning loadMore is not supported. return; } if (limit > 0) { setRequestLimit

(this->m_parameters, limit); } this->setStatus(suggestedModelStatus); // We never want to set this while the loader is running, hence the Mutex and setting it here // instead when Loader::setApiClient is called. this->m_loader->setApiClient(this->m_apiClient); this->m_loader->setParameters(this->m_parameters); this->m_loader->prepareLoad(); QFuture> future = QtConcurrent::run(this->m_loader.data(), &Support::Loader::load); this->m_futureWatcher.setFuture(future); } QScopedPointer> m_loader; QMutex m_mutex; P m_parameters; QFutureWatcher> m_futureWatcher; void futureReady() override { R result; try { std::optional optResult = m_futureWatcher.result(); if (!optResult.has_value()) { this->setStatus(ViewModel::ModelStatus::Error); qWarning() << "ModelLoader returned with empty optional"; return; } result = optResult.value(); } catch (Support::LoadException &e) { qWarning() << "Exception while loading: " << e.what(); this->setStatus(ViewModel::ModelStatus::Error); return; } QList records = extractRecords(result); int totalRecordCount = extractTotalRecordCount(result); qDebug() << "Total record count: " << totalRecordCount << ", records in request: " << records.size(); // If totalRecordCount < 0, it is not supported for this endpoint if (totalRecordCount < 0) { totalRecordCount = records.size(); } QList models; models.reserve(records.size()); // Convert the DTOs into models for (int i = 0; i < records.size(); i++) { models.append(new T(records[i], m_loader->apiClient())); } this->setStatus(ViewModel::ModelStatus::Ready); this->m_result = { models, this->m_startIndex}; this->m_startIndex += totalRecordCount; this->emitItemsLoaded(); } }; class BaseApiModel : public QAbstractListModel { Q_OBJECT public: BaseApiModel(QObject *parent = nullptr) : QAbstractListModel (parent) {} Q_PROPERTY(BaseModelLoader *loader READ loader WRITE setLoader NOTIFY loaderChanged) virtual BaseModelLoader *loader() const = 0; virtual void setLoader(BaseModelLoader *newLoader) { connect(newLoader, &BaseModelLoader::reloadWanted, this, &BaseApiModel::reload); connect(newLoader, &BaseModelLoader::modelShouldClear, this, &BaseApiModel::clear); connect(newLoader, &BaseModelLoader::itemsLoaded, this, &BaseApiModel::loadingFinished); emit loaderChanged(); }; void disconnectOldLoader(BaseModelLoader *oldLoader) { if (oldLoader != nullptr) { // Disconnect all signals disconnect(oldLoader, nullptr, this, nullptr); } } public slots: virtual void reload() = 0; virtual void clear() = 0; protected slots: virtual void loadingFinished() = 0; signals: void loaderChanged(); }; /** * @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". * * @tparam T The class of the result. * @tparam R The class returned by the loader. * @tparam P The class with the request parameters for the loader. * */ template class ApiModel : public BaseApiModel { public: /** * @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(QObject *parent = nullptr) : BaseApiModel(parent) { } // Standard QAbstractItemModel overrides int rowCount(const QModelIndex &index) const override { if (!index.isValid()) return m_array.size(); return 0; } // QList-like API QSharedPointer at(int index) const { return m_array.at(index); } /** * @return the amount of objects in this model. */ int size() const { return m_array.size(); } void insert(int index, QSharedPointer object) { Q_ASSERT(index >= 0 && index <= size()); this->beginInsertRows(QModelIndex(), index, index); m_array.insert(index, object); this->endInsertRows(); } void append(QSharedPointer object) { insert(size(), object); } //void append(T &object) { insert(size(), object); } void append(QList> &objects) { int index = size(); this->beginInsertRows(QModelIndex(), index, index + objects.size() - 1); m_array.append(objects); this->endInsertRows(); }; QList mid(int pos, int length = -1) { return m_array.mid(pos, length); } void removeAt(int index) { Q_ASSERT(index < size()); this->beginRemoveRows(QModelIndex(), index, index); m_array.removeAt(index); this->endRemoveRows(); } void removeUntilEnd(int from) { this->beginRemoveRows(QModelIndex(), from , m_array.size()); while (m_array.size() != from) { m_array.removeLast(); } this->endRemoveRows(); } void removeOne(QSharedPointer object) { int idx = m_array.indexOf(object); if (idx >= 0) { removeAt(idx); } } void clear() override { this->beginResetModel(); m_array.clear(); this->endResetModel(); } // From AbstractListModel, gets implemented in ApiModel //virtual QHash roleNames() const override = 0; /*virtual QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override = 0;*/ virtual bool canFetchMore(const QModelIndex &parent) const override { if (parent.isValid()) return false; if (m_loader == nullptr) return false; return m_loader->canLoadMore(); } virtual void fetchMore(const QModelIndex &parent) override { if (parent.isValid()) return; if (m_loader != nullptr) { m_loader->loadMore(); } } BaseModelLoader *loader() const override { return m_loader; } void setLoader(BaseModelLoader *newLoader) { ModelLoader *castedLoader = dynamic_cast *>(newLoader); if (castedLoader != nullptr) { // Hacky way to emit a signal BaseApiModel::setLoader(newLoader); BaseApiModel::disconnectOldLoader(m_loader); m_loader = castedLoader; } else { qWarning() << "Somehow set a BaseModelLoader on ApiModel instead of a ModelLoader"; } } void reload() override { if (m_loader != nullptr) { m_loader->reload(); } } protected: // Model-specific properties. QList> m_array; ModelLoader *m_loader = nullptr; void loadingFinished() override { Q_ASSERT(m_loader != nullptr); std::pair, int> result = m_loader->result(); qDebug() << "Results loaded: index: " << result.second << ", count: " << result.first.size(); if (result.second == -1) { clear(); } else if (result.second == m_array.size()) { m_array.reserve(m_array.size() + result.second); for (auto it = result.first.begin(); it != result.first.end(); it++) { append(QSharedPointer(*it)); } } else if (result.second < m_array.size()){ removeUntilEnd(result.second); m_array.reserve(m_array.size() + result.second); for (auto it = result.first.begin(); it != result.first.end(); it++) { append(QSharedPointer(*it)); } } else { // result.second > m_array.size() qWarning() << "Retrieved data from loader at position bigger than size()"; } } private: QMetaObject::Connection m_futureWatcherConnection; }; /** * @brief List of the public users on the server. */ /*class PublicUserModel : public ApiModel { public: explicit PublicUserModel (QObject *parent = nullptr); };*/ //template<> //void ApiModel::apiClientChanged(); void registerModels(const char *URI); } #endif //JELLYFIN_API_MODEL