/* 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 */ #include "JellyfinQt/apimodel.h" #include "JellyfinQt/DTO/item.h" #include "JellyfinQt/DTO/useritemdatadto.h" #include "JellyfinQt/DTO/userdto.h" namespace Jellyfin { // BaseApiModel namespace DTO { using UserData = DTO::UserItemDataDto; using User = DTO::UserDto; } using User = DTO::UserDto; BaseApiModel::BaseApiModel(QString path, bool hasRecordResponse, bool addUserId, QObject *parent) : QAbstractListModel(parent), m_path(path), m_hasRecordResponse(hasRecordResponse), m_addUserId(addUserId) { } void BaseApiModel::setApiClient(ApiClient *apiClient) { m_apiClient = apiClient; emit apiClientChanged(m_apiClient); } void BaseApiModel::setLimit(int newLimit) { m_limit = newLimit; emit limitChanged(newLimit); if (m_apiClient != nullptr && !m_isBeingParsed) { load(LOAD_MORE); } } void BaseApiModel::reload() { this->setStatus(Loading); m_startIndex = 0; load(RELOAD); } void BaseApiModel::load(LoadType type) { qDebug() << (type == RELOAD ? "RELOAD" : "LOAD_MORE"); if (m_apiClient == nullptr) { qWarning() << "Please set the apiClient property before (re)loading"; return; } QString path(m_path); replacePathPlaceholders(path); QUrlQuery query; addQueryParameters(query); QNetworkReply *rep = m_apiClient->get(path, query); connect(rep, &QNetworkReply::finished, this, [this, type, rep]() { qDebug() << rep->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt() << ": " << rep->request().url(); QJsonDocument doc = QJsonDocument::fromJson(rep->readAll()); if (doc.isNull()) { qWarning() << "JSON parse error"; this->setStatus(Error); } if (!m_hasRecordResponse) { if (!doc.isArray()) { qWarning() << "Object is not an array!"; this->setStatus(Error); return; } QJsonArray items = doc.array(); setModelData(items); } 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: setModelData(items); break; case LOAD_MORE: appendModelData(items); break; } } this->setStatus(Ready); rep->deleteLater(); }); } void BaseApiModel::addQueryParameters(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_sortBy.empty()) { query.addQueryItem("SortBy", m_sortBy.join(",")); } if (m_sortOrder != Unspecified) { query.addQueryItem("SortOrder", m_sortOrder == Ascending ? "Ascending" : "Descending"); } if (!m_fields.empty()) { query.addQueryItem("Fields", m_fields.join(",")); } if (m_addUserId) { query.addQueryItem("userId", m_apiClient->userId()); } } void BaseApiModel::replacePathPlaceholders(QString &path) { if (path.contains("{{user}}")) { path = path.replace("{{user}}", m_apiClient->userId()); } } void BaseApiModel::classBegin() { m_isBeingParsed = true; } void BaseApiModel::componentComplete() { m_isBeingParsed = false; } // ApiModel template ApiModel::ApiModel(QString path, bool hasRecordResponse, bool addUserId, QObject *parent) : BaseApiModel(path, hasRecordResponse, addUserId, parent) { // If based on QObject, we know our role names before the first request generateFields(); } template <> ApiModel::ApiModel(QString path, bool hasRecordResponse, bool addUserId, QObject *parent) : BaseApiModel(path, hasRecordResponse, addUserId, parent) { // But we only know our role names after our first request. } template T *ApiModel::deserializeResult(QJsonValueRef source) { T *result = T::fromJSON(source.toObject(), this); return result; } template <> QJsonValue *ApiModel::deserializeResult(QJsonValueRef source) { QJsonValue *result = new QJsonValue(source); JsonHelper::convertToCamelCase(*result); return result; } template void ApiModel::generateFields() { const QMetaObject *obj = &T::staticMetaObject; m_roles[Qt::UserRole + 1] = "qtObject"; for (int i = 0; i < obj->propertyCount(); i++) { QMetaProperty property = obj->property(i); m_roles.insert(Qt::UserRole + 2 + i, property.name()); } } template <> void ApiModel::generateFields() { // We can only generate field names if there is a first item. Redefining role names later leads to // unexpected results, so prevent it as well. if (m_array.size() == 0 || m_roles.size() > 0) return; 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); } } } template void ApiModel::setModelData(QJsonArray &data) { this->beginResetModel(); for (T* value : m_array) { value->deleteLater(); } m_array.clear(); for(QJsonValueRef value : data) { m_array.append(deserializeResult(value)); } this->endResetModel(); } template <> void ApiModel::setModelData(QJsonArray &data) { generateFields(); this->beginResetModel(); for (QJsonValue* value : m_array) { delete value; } m_array.clear(); for(QJsonValueRef value : data) { m_array.append(deserializeResult(value)); } this->endResetModel(); } template void ApiModel::appendModelData(QJsonArray &data) { this->beginInsertRows(QModelIndex(), m_array.size(), m_array.size() + data.size() - 1); // QJsonArray apparently doesn't allow concatenating lists like QList or std::vector for (auto it = data.begin(); it != data.end(); it++) { JsonHelper::convertToCamelCase(*it); } for(QJsonValueRef val : data) { m_array.append(deserializeResult(val)); } this->endInsertRows(); } template 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(); T* obj = m_array.at(index.row()); // m_roleNames[role] == "qtObject" if (role == Qt::UserRole + 1) { return QVariant::fromValue(obj); } const QString &key = m_roles[role]; if (role - Qt::UserRole - 2 < obj->metaObject()->propertyCount() ) { return obj->property(key.toLocal8Bit()); } return QVariant(); } template <> 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(); const QString &key = m_roles[role]; if (obj.contains(key)) { return obj[key].toVariant(); } return QVariant(); } template bool ApiModel::canFetchMore(const QModelIndex &parent) const { if (parent.isValid()) return false; switch(m_status) { case Uninitialised: case Loading: case LoadingMore: return false; default: break; } if (m_limit < 0) { return m_startIndex <= m_totalRecordCount; } else { return false; } } template void ApiModel::fetchMore(const QModelIndex &parent) { if (parent.isValid()) return; this->setStatus(LoadingMore); load(LOAD_MORE); } template void ApiModel::addQueryParameters(QUrlQuery &query) { BaseApiModel::addQueryParameters(query); } template void ApiModel::replacePathPlaceholders(QString &path) { BaseApiModel::replacePathPlaceholders(path); } template void ApiModel::insert(int index, T* object) { Q_ASSERT(index >=0 && index <= size()); this->beginInsertRows(QModelIndex(), index, index); m_array.insert(index, object); this->endInsertRows(); } template void ApiModel::removeAt(int index) { this->beginRemoveRows(QModelIndex(), index, index); m_array.removeAt(index); this->endRemoveRows(); } template void ApiModel::removeOne(T* object) { int idx = m_array.indexOf(object); if (idx >= 0) { removeAt(idx); } } // Itemmodel ItemModel::ItemModel(QString path, bool hasRecordFields, bool replaceUser, QObject *parent) : ApiModel (path, hasRecordFields, replaceUser, parent){ QObject::connect(this, &BaseApiModel::apiClientChanged, static_cast(this), [this](ApiClient *newApiClient) { QObject::connect(newApiClient, &ApiClient::userDataChanged, this, &ItemModel::onUserDataChanged); }); } void ItemModel::onUserDataChanged(const QString &itemId, DTO::UserData *userData) { int i = 0; for (Item *val: m_array) { if (val->userData() != nullptr && val->jellyfinId() == itemId) { QModelIndex cell = this->index(i); // val->userData()->onUpdated(userData); this->dataChanged(cell, cell); } i++; } } void ItemModel::addQueryParameters(QUrlQuery &query) { ApiModel::addQueryParameters(query); if (!m_parentId.isEmpty()) { query.addQueryItem("ParentId", m_parentId); } if (!m_imageTypes.empty()) { query.addQueryItem("ImageTypes", m_imageTypes.join(",")); } if (!m_includeItemTypes.empty()) { query.addQueryItem("IncludeItemTypes", m_includeItemTypes.join(",")); } if (!m_seasonId.isEmpty()) { query.addQueryItem("seasonId", m_seasonId); } if (m_recursive) { query.addQueryItem("Recursive", "true"); } } void ItemModel::replacePathPlaceholders(QString &path) { ApiModel::replacePathPlaceholders(path); if (path.contains("{{show}}") && !m_show.isEmpty()) { path = m_path.replace("{{show}}", m_show); } } PublicUserModel::PublicUserModel(QObject *parent) : ApiModel ("/users/public", false, false, parent) { } UserViewModel::UserViewModel(QObject *parent) : ItemModel ("/Users/{{user}}/Views", true, false, parent) {} UserItemModel::UserItemModel(QObject *parent) : ItemModel ("/Users/{{user}}/Items", true, false, parent) {} UserItemResumeModel::UserItemResumeModel(QObject *parent) : ItemModel ("/Users/{{user}}/Items/Resume", true, false, parent) {} UserItemLatestModel::UserItemLatestModel(QObject *parent) : ItemModel ("/Users/{{user}}/Items/Latest", false, false, parent) {} ShowNextUpModel::ShowNextUpModel(QObject *parent) : ItemModel("/Shows/NextUp", true, true, parent) {} ShowSeasonsModel::ShowSeasonsModel(QObject *parent) : ItemModel ("/Shows/{{show}}/Seasons", true, true, parent) {} ShowEpisodesModel::ShowEpisodesModel(QObject *parent) : ItemModel ("/Shows/{{show}}/Episodes", true, true, parent) {} void registerModels(const char *URI) { qmlRegisterUncreatableType(URI, 1, 0, "ApiModel", "Is enum and base class"); qmlRegisterUncreatableType(URI, 1, 0, "SortOptions", "Is enum"); qmlRegisterType(URI, 1, 0, "PublicUserModel"); qmlRegisterType(URI, 1, 0, "UserViewModel"); qmlRegisterType(URI, 1, 0, "UserItemModel"); qmlRegisterType(URI, 1, 0, "UserItemLatestModel"); qmlRegisterType(URI, 1, 0, "UserItemResumeModel"); qmlRegisterType(URI, 1, 0, "ShowNextUpModel"); qmlRegisterType(URI, 1, 0, "ShowSeasonsModel"); qmlRegisterType(URI, 1, 0, "ShowEpisodesModel"); } }