1 /*
2     SPDX-FileCopyrightText: 2021 Julius Künzel <jk.kdedev@smartlab.uber.space>
3     SPDX-FileCopyrightText: 2011 Jean-Baptiste Mardelle <jb@kdenlive.org>
4     SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
5 */
6 
7 #include "providermodel.hpp"
8 #include "kdenlive_debug.h"
9 #include "kdenlivesettings.h"
10 
11 #include <QDateTime>
12 #include <QDir>
13 #include <QFile>
14 #include <QJsonValue>
15 #include <QJsonArray>
16 #include <QUrlQuery>
17 #include <KMessageBox>
18 #include <klocalizedstring.h>
19 #include <kio/storedtransferjob.h>
20 #include <QNetworkAccessManager>
21 #include <QNetworkRequest>
22 #include <QNetworkReply>
23 #include <QDesktopServices>
24 
ProviderModel(const QString & path)25 ProviderModel::ProviderModel(const QString &path)
26     : m_path(path)
27     , m_invalid(false)
28 {
29 
30     QFile file(path);
31     QJsonParseError jsonError;
32 
33     if (!file.exists()) {
34         qCWarning(KDENLIVE_LOG) << "WARNING, can not find provider configuration file at" << path << ".";
35         m_invalid = true;
36     } else {
37         file.open(QFile::ReadOnly);
38         m_doc = QJsonDocument::fromJson(file.readAll(), &jsonError);
39         if (jsonError.error != QJsonParseError::NoError) {
40             m_invalid = true;
41             // There was an error parsing data
42             KMessageBox::error(nullptr, jsonError.errorString(), i18nc("@title:window", "Error Loading Data"));
43             return;
44         }
45         validate();
46     }
47 
48     if(!m_invalid) {
49         m_apiroot = m_doc["api"].toObject()["root"].toString();
50         m_search = m_doc["api"].toObject()["search"].toObject();
51         m_download = m_doc["api"].toObject()["downloadUrls"].toObject();
52         m_name = m_doc["name"].toString();
53         m_clientkey = m_doc["clientkey"].toString();
54         m_attribution = m_doc["attributionHtml"].toString();
55         m_homepage = m_doc["homepage"].toString();
56 
57         #ifndef DOXYGEN_SHOULD_SKIP_THIS // don't make this any more public than it is.
58         if(!m_clientkey.isEmpty()) {
59             //all these keys are registered with online-resources@kdenlive.org
60             m_clientkey.replace("%freesound_apikey%","aJuPDxHP7vQlmaPSmvqyca6YwNdP0tPaUnvmtjIn");
61             m_clientkey.replace("%pexels_apikey%","563492ad6f91700001000001c2c34d4986e5421eb353e370ae5a89d0");
62             m_clientkey.replace("%pixabay_apikey%","20228828-57acfa09b69e06ae394d206af");
63         }
64         #endif
65 
66         if( m_doc["type"].toString() == "music")  {
67             m_type = SERVICETYPE::AUDIO;
68         } else if( m_doc["type"].toString() == "sound")  {
69             m_type = SERVICETYPE::AUDIO;
70         } else if( m_doc["type"].toString() == "video")  {
71             m_type = SERVICETYPE::VIDEO;
72         } else if( m_doc["type"].toString() == "image")  {
73             m_type = SERVICETYPE::IMAGE;
74         } else {
75             m_type = SERVICETYPE::UNKNOWN;
76         }
77 
78         if(downloadOAuth2() == true) {
79             QJsonObject ouath2Info= m_doc["api"].toObject()["oauth2"].toObject();
80             auto replyHandler = new QOAuthHttpServerReplyHandler(1337, this);
81             m_oauth2.setReplyHandler(replyHandler);
82             m_oauth2.setAuthorizationUrl(QUrl(ouath2Info["authorizationUrl"].toString()));
83             m_oauth2.setAccessTokenUrl(QUrl(ouath2Info["accessTokenUrl"].toString()));
84             m_oauth2.setClientIdentifier(ouath2Info["clientId"].toString());
85             m_oauth2.setClientIdentifierSharedKey(m_clientkey);
86 #if QT_VERSION >= QT_VERSION_CHECK(5,12,0)
87             connect(&m_oauth2, &QOAuth2AuthorizationCodeFlow::refreshTokenChanged, this, [&](const QString &refreshToken){
88 #else
89             connect(&m_oauth2, &QOAuth2AuthorizationCodeFlow::tokenChanged, this, [&](const QString &refreshToken){
90 #endif
91                 KSharedConfigPtr config = KSharedConfig::openConfig();
92                 KConfigGroup authGroup(config, "OAuth2Authentication" + m_name);
93                 authGroup.writeEntry(QStringLiteral("refresh_token"), refreshToken);
94             });
95 
96             m_oauth2.setModifyParametersFunction([&](QAbstractOAuth::Stage stage, QVariantMap *parameters) {
97                 if (stage == QAbstractOAuth::Stage::RequestingAuthorization) {
98                     if(m_oauth2.scope().isEmpty()) {
99                         parameters->remove("scope");
100                     }
101                 }
102                 if (stage == QAbstractOAuth::Stage::RefreshingAccessToken) {
103                     parameters->insert("client_id", m_oauth2.clientIdentifier());
104                     parameters->insert("client_secret", m_oauth2.clientIdentifierSharedKey());
105                 }
106 
107             });
108 
109             connect(&m_oauth2, &QOAuth2AuthorizationCodeFlow::statusChanged, this, [=](QAbstractOAuth::Status status) {
110                 if (status == QAbstractOAuth::Status::Granted ) {
111                     emit authenticated(m_oauth2.token());
112                 } else if (status == QAbstractOAuth::Status::NotAuthenticated) {
113                     KMessageBox::error(nullptr, "DEBUG: NotAuthenticated");
114                 }
115             });
116             connect(&m_oauth2, &QOAuth2AuthorizationCodeFlow::error, this, [=](const QString &error, const QString &errorDescription) {
117                 qCWarning(KDENLIVE_LOG) << "Error in authorization flow. " << error << " " << errorDescription;
118                 emit authenticated(QString());
119             });
120             connect(&m_oauth2, &QOAuth2AuthorizationCodeFlow::authorizeWithBrowser, &QDesktopServices::openUrl);
121         }
122     } else {
123         qCWarning(KDENLIVE_LOG) << "The provider config file at " << path << " is invalid. ";
124     }
125 }
126 
127 void ProviderModel::authorize() {
128     KSharedConfigPtr config = KSharedConfig::openConfig();
129     KConfigGroup authGroup(config, "OAuth2Authentication" + m_name);
130 
131     QString strRefreshTokenFromSettings = authGroup.readEntry(QStringLiteral("refresh_token"));
132 
133     if(m_oauth2.token().isEmpty()) {
134         if (!strRefreshTokenFromSettings.isEmpty()) {
135             m_oauth2.setRefreshToken(strRefreshTokenFromSettings);
136             m_oauth2.refreshAccessToken();
137         } else {
138             m_oauth2.grant();
139         }
140     }  else {
141         if(m_oauth2.expirationAt() < QDateTime::currentDateTime()) {
142             emit authenticated(m_oauth2.token());
143         } else {
144             m_oauth2.refreshAccessToken();
145         }
146 
147     }
148 }
149 
150 void ProviderModel::refreshAccessToken() {
151     m_oauth2.refreshAccessToken();
152 }
153 
154 /**
155  * @brief ProviderModel::validate
156  * Check if config has all required fields. Result can be gotten with is_valid()
157  */
158 void ProviderModel::validate() {
159 
160     m_invalid = true;
161     if(m_doc.isNull() || m_doc.isEmpty() || !m_doc.isObject()) {
162         qCWarning(KDENLIVE_LOG) << "Root object missing or invalid";
163         return;
164     }
165 
166     if(!m_doc["integration"].isString() || m_doc["integration"].toString() != "buildin") {
167         qCWarning(KDENLIVE_LOG) << "Currently only integration type \"buildin\" is supported";
168         return;
169     }
170 
171     if(!m_doc["name"].isString() ) {
172         qCWarning(KDENLIVE_LOG) << "Missing key name of type string ";
173         return;
174     }
175 
176     if(!m_doc["homepage"].isString() ) {
177         qCWarning(KDENLIVE_LOG) << "Missing key homepage of type string ";
178         return;
179     }
180 
181     if(!m_doc["type"].isString() ) {
182         qCWarning(KDENLIVE_LOG) << "Missing key type of type string ";
183         return;
184     }
185 
186     if(!m_doc["api"].isObject() || !m_doc["api"].toObject()["search"].isObject()) {
187         qCWarning(KDENLIVE_LOG)  << "Missing api of type object or key search of type object";
188         return;
189     }
190     if(downloadOAuth2()) {
191         if(!m_doc["api"].toObject()["oauth2"].isObject()) {
192             qCWarning(KDENLIVE_LOG) << "Missing OAuth2 configuration (required)";
193             return;
194         }
195         if(m_doc["api"].toObject()["oauth2"].toObject()["authorizationUrl"].toString().isEmpty()) {
196             qCWarning(KDENLIVE_LOG) << "Missing authorizationUrl for OAuth2";
197             return;
198         }
199         if(m_doc["api"].toObject()["oauth2"].toObject()["accessTokenUrl"].toString().isEmpty()) {
200             qCWarning(KDENLIVE_LOG) << "Missing accessTokenUrl for OAuth2";
201             return;
202         }
203         if(m_doc["api"].toObject()["oauth2"].toObject()["clientId"].toString().isEmpty()) {
204             qCWarning(KDENLIVE_LOG) << "Missing clientId for OAuth2";
205             return;
206         }
207     }
208 
209     m_invalid = false;
210 }
211 
212 bool ProviderModel::is_valid() const {
213     return !m_invalid;
214 }
215 
216 QString ProviderModel::name() const {
217     return m_name;
218 }
219 
220 QString ProviderModel::homepage() const {
221     return m_homepage;
222 }
223 
224 ProviderModel::SERVICETYPE ProviderModel::type() const {
225     return m_type;
226 }
227 
228 QString ProviderModel::attribution() const {
229     return m_attribution;
230 }
231 
232 bool ProviderModel::downloadOAuth2() const {
233     return m_doc["downloadOAuth2"].toBool(false);
234 }
235 
236 bool ProviderModel::requiresLogin() const {
237     if(downloadOAuth2()) {
238         KSharedConfigPtr config = KSharedConfig::openConfig();
239         KConfigGroup authGroup(config, "OAuth2Authentication" + m_name);
240         authGroup.exists();
241 
242         return !authGroup.exists() || authGroup.readEntry(QStringLiteral("refresh_token")).isEmpty();
243     }
244     return false;
245 }
246 
247 /**
248  * @brief ProviderModel::objectGetValue
249  * @param item Object containing the value
250  * @param key General key of value to get
251  * @return value
252  * Gets a value of item identified by key. The key is translated to the key the provider uses (configured in the providers config file)
253  * E.g. the provider uses "photographer" as key for the author and another provider uses "user".
254  * With this function you can simply use "author" as key no matter of the providers specific key.
255  * In addition this function takes care of modifiers like "$" for placeholders, etc. but does not parse them (use objectGetString for this purpose)
256  */
257 
258 QJsonValue ProviderModel::objectGetValue(QJsonObject item, QString key) {
259     QJsonObject tmpKeys = m_search["res"].toObject();
260     if(key.contains(".")) {
261         QStringList subkeys = key.split(".");
262 
263         for (const auto &subkey : qAsConst(subkeys)) {
264             if(subkeys.indexOf(subkey) == subkeys.indexOf(subkeys.last())) {
265                 key = subkey;
266             } else {
267                 tmpKeys = tmpKeys[subkey].toObject();
268             }
269         }
270     }
271 
272     QString parseKey = tmpKeys[key].toString();
273     // "$" means template, store template string instead of using as key
274     if(parseKey.startsWith("$")) {
275         return tmpKeys[key];
276     }
277 
278     // "." in key means value is in a subobject
279     if(parseKey.contains(".")) {
280         QStringList subkeys = tmpKeys[key].toString().split(".");
281 
282         for (const auto &subkey : qAsConst(subkeys)) {
283             if(subkeys.indexOf(subkey) == subkeys.indexOf(subkeys.last())) {
284                 parseKey = subkey;
285             } else {
286                 item = item[subkey].toObject();
287             }
288 
289         }
290     }
291 
292     // "%" means placeholder, store placeholder instead of using as key
293     if(parseKey.startsWith("%")) {
294         return tmpKeys[key];
295     }
296 
297     return item[parseKey];
298 }
299 
300 /**
301  * @brief ProviderModel::objectGetString
302  * @param item Object containing the value
303  * @param key General key of value to get
304  * @param id The id is used for to replace the palceholder "%id%" (optional)
305  * @param parentKey Key of the parent (json) object. Used for to replace the palceholder "&" (optional)
306  * @return result string
307  * Same as objectGetValue but more specific only for strings. In addition this function parses template strings and palceholders.
308  */
309 
310 QString ProviderModel::objectGetString(QJsonObject item, QString key, const QString &id, const QString &parentKey) {
311     QJsonValue val = objectGetValue(item, key);
312     if(!val.isString()) {
313         return QString();
314     }
315     QString result = val.toString();
316 
317     if(result.startsWith("$")) {
318         result = result.replace("%id%", id);
319         QStringList sections = result.split("{");
320         for (auto &section : sections) {
321             section.remove("{");
322             section.remove(section.indexOf("}"), section.length());
323 
324             // "&" is a placeholder for the parent key
325             if(section.startsWith("&")) {
326                 result.replace("{" + section + "}", parentKey);
327             } else {
328                 result.replace("{" + section + "}", item[section].isDouble() ? QString::number(item[section].toDouble()) : item[section].toString());
329             }
330         }
331         result.remove("$");
332     }
333 
334     return result;
335 }
336 
337 QString ProviderModel::replacePlaceholders(QString string, const QString query, const int page, const QString id) {
338     string = string.replace("%query%", query);
339     string = string.replace("%pagenum%", QString::number(page));
340     string = string.replace("%perpage%", QString::number(m_perPage));
341     string = string.replace("%shortlocale%", "en-US"); //TODO
342     string = string.replace("%clientkey%", m_clientkey);
343     string = string.replace("%id%", id);
344 
345     return string;
346 }
347 
348 /**
349  * @brief ProviderModel::getFilesUrl
350  * @param searchText The search query
351  * @param page The page to request
352  * Get the url to search for items
353  */
354 QUrl ProviderModel::getSearchUrl(const QString &searchText, const int page) {
355 
356     QUrl url(m_apiroot);
357     const QJsonObject req = m_search["req"].toObject();
358     QUrlQuery query;
359     url.setPath(url.path().append(req["path"].toString()));
360 
361     for (const auto param : req["params"].toArray()) {
362         query.addQueryItem(param.toObject()["key"].toString(), replacePlaceholders(param.toObject()["value"].toString(), searchText, page));
363     }
364     url.setQuery(query);
365 
366     return url;
367 }
368 
369 /**
370  * @brief ProviderModel::slotFetchFiles
371  * @param searchText The search query
372  * @param page The page to request
373  * Fetch metadata about the available files, if they are not included in the search response (e.g. archive.org)
374  */
375 void ProviderModel::slotStartSearch(const QString &searchText, const int page)
376 {
377     QUrl uri = getSearchUrl(searchText, page);
378 
379     if(m_search["req"].toObject()["method"].toString() == "GET") {
380 
381         auto *manager = new QNetworkAccessManager(this);
382 
383         QNetworkRequest request(uri);
384 
385         if(m_search["req"].toObject()["header"].isArray()) {
386             for (const auto &header: m_search["req"].toObject()["header"].toArray()) {
387                 request.setRawHeader(header.toObject()["key"].toString().toUtf8(), replacePlaceholders(header.toObject()["value"].toString(), searchText, page).toUtf8());
388             }
389         }
390         QNetworkReply *reply = manager->get(request);
391 
392         connect(reply, &QNetworkReply::finished, this, [=]() {
393             if(reply->error() == QNetworkReply::NoError) {
394                 QByteArray response = reply->readAll();
395                 std::pair<QList<ResourceItemInfo>, const int> result = parseSearchResponse(response);
396                 emit searchDone(result.first, result.second);
397 
398             } else {
399               emit searchError(QStringLiteral("HTTP ") + reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toString());
400               qCDebug(KDENLIVE_LOG) << reply->errorString();
401             }
402             reply->deleteLater();
403         });
404 
405         connect(reply, &QNetworkReply::sslErrors, this, [=]() {
406             emit searchError(QStringLiteral("HTTP ") + reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toString());
407             qCDebug(KDENLIVE_LOG) << reply->errorString();
408         });
409 
410     } else {
411         qCDebug(KDENLIVE_LOG) << "Only GET is implemented yet";
412     }
413 }
414 
415 /**
416  * @brief ProviderModel::parseFilesResponse
417  * @param data Response data of the api request
418  * @return pair of  of QList containing ResourceItemInfo and int reflecting the number of found pages
419  * Parse the response data of a search request, usually after slotStartSearch
420  */
421 std::pair<QList<ResourceItemInfo>, const int> ProviderModel::parseSearchResponse(const QByteArray &data) {
422     QJsonObject keys = m_search["res"].toObject();
423     QList<ResourceItemInfo> list;
424     int pageCount = 0;
425     if(keys["format"].toString() == "json") {
426         QJsonDocument res = QJsonDocument::fromJson(data);
427 
428         QJsonArray items;
429         if(keys["list"].toString("").isEmpty()) {
430             items = res.array();
431         } else {
432             items = objectGetValue(res.object(), "list").toArray();
433         }
434 
435         pageCount = objectGetValue(res.object(), "resultCount").toInt() / m_perPage;
436 
437         for (const auto &item : qAsConst(items)) {
438             ResourceItemInfo onlineItem;
439             onlineItem.author = objectGetString(item.toObject(), "author");
440             onlineItem.authorUrl = objectGetString(item.toObject(), "authorUrl");
441             onlineItem.name = objectGetString(item.toObject(), "name");
442             onlineItem.filetype = objectGetString(item.toObject(), "filetype");
443             onlineItem.description = objectGetString(item.toObject(), "description");
444             onlineItem.id = (objectGetValue(item.toObject(), "id").isString() ? objectGetString(item.toObject(), "id") : QString::number(objectGetValue(item.toObject(), "id").toInt()));
445             onlineItem.infoUrl = objectGetString(item.toObject(), "url");
446             onlineItem.license = objectGetString(item.toObject(), "licenseUrl");
447             onlineItem.imageUrl = objectGetString(item.toObject(), "imageUrl");
448             onlineItem.previewUrl = objectGetString(item.toObject(), "previewUrl");
449             onlineItem.width = objectGetValue(item.toObject(), "width").toInt();
450             onlineItem.height = objectGetValue(item.toObject(), "height").toInt();
451             onlineItem.duration = objectGetValue(item.toObject(), "duration").isDouble() ? int(objectGetValue(item.toObject(), "duration").toDouble()) : objectGetValue(item.toObject(), "duration").toInt();
452 
453             if(keys["downloadUrls"].isObject()) {
454                 for (const auto urlItem : objectGetValue(item.toObject(), "downloadUrls.key").toArray()) {
455                     onlineItem.downloadUrls << objectGetString(urlItem.toObject(), "downloadUrls.url");
456                     onlineItem.downloadLabels << objectGetString(urlItem.toObject(), "downloadUrls.name");
457                 }
458                 if (onlineItem.previewUrl.isEmpty()) {
459                     onlineItem.previewUrl = onlineItem.downloadUrls.first();
460                 }
461             } else if(keys["downloadUrl"].isString()){
462                 onlineItem.downloadUrl = objectGetString(item.toObject(), "downloadUrl");
463                 if (onlineItem.previewUrl.isEmpty()) {
464                     onlineItem.previewUrl = onlineItem.downloadUrl;
465                 }
466             }
467 
468             list << onlineItem;
469         }
470     } else {
471         qCWarning(KDENLIVE_LOG) << "WARNING: unknown response format: " << keys["format"];
472     }
473     return std::pair<QList<ResourceItemInfo>, const int> (list, pageCount);
474 }
475 
476 /**
477  * @brief ProviderModel::getFilesUrl
478  * @param id The providers id of the item the data should be fetched for
479  * @return the url
480  * Get the url to fetch metadata about the available files.
481  */
482 QUrl ProviderModel::getFilesUrl(const QString &id) {
483 
484     QUrl url(m_apiroot);
485     if(!m_download["req"].isObject()) {
486         return QUrl();
487     }
488     const QJsonObject req = m_download["req"].toObject();
489     QUrlQuery query;
490     url.setPath(url.path().append(replacePlaceholders(req["path"].toString(), QString(), 0, id)));
491 
492     for (const auto param : req["params"].toArray()) {
493         query.addQueryItem(param.toObject()["key"].toString(), replacePlaceholders(param.toObject()["value"].toString(), QString(), 0, id));
494     }
495 
496     url.setQuery(query);
497 
498     return url;
499 }
500 
501 /**
502  * @brief ProviderModel::slotFetchFiles
503  * @param id The providers id of the item the date should be fetched for
504  * Fetch metadata about the available files, if they are not included in the search response (e.g. archive.org)
505  */
506 void ProviderModel::slotFetchFiles(const QString &id) {
507 
508     QUrl uri = getFilesUrl(id);
509 
510     if(uri.isEmpty()) {
511         return;
512     }
513 
514     if(m_download["req"].toObject()["method"].toString() == "GET") {
515 
516         auto *manager = new QNetworkAccessManager(this);
517 
518         QNetworkRequest request(uri);
519 
520         if(m_download["req"].toObject()["header"].isArray()) {
521             for (const auto &header: m_search["req"].toObject()["header"].toArray()) {
522                 request.setRawHeader(header.toObject()["key"].toString().toUtf8(), replacePlaceholders(header.toObject()["value"].toString()).toUtf8());
523             }
524         }
525         QNetworkReply *reply = manager->get(request);
526 
527         connect(reply, &QNetworkReply::finished, this, [=]() {
528             if(reply->error() == QNetworkReply::NoError) {
529                 QByteArray response = reply->readAll();
530                 std::pair<QStringList, QStringList> result = parseFilesResponse(response, id);
531                 emit fetchedFiles(result.first, result.second);
532                 reply->deleteLater();
533             }
534             else {
535               emit fetchedFiles(QStringList(),QStringList());
536               qCDebug(KDENLIVE_LOG) << reply->errorString();
537             }
538         });
539 
540         connect(reply, &QNetworkReply::sslErrors, this, [=]() {
541             emit fetchedFiles(QStringList(), QStringList());
542             qCDebug(KDENLIVE_LOG) << reply->errorString();
543         });
544 
545     } else {
546         qCDebug(KDENLIVE_LOG) << "Only GET is implemented yet";
547     }
548 }
549 
550 /**
551  * @brief ProviderModel::parseFilesResponse
552  * @param data Response data of the api request
553  * @param id The providers id of the item the date should be fetched for
554  * @return pair of two QStringList First list contains urls to files, second list contains labels describing the files
555  * Parse the response data of a fetch files request, usually after slotFetchFiles
556  */
557 std::pair<QStringList, QStringList> ProviderModel::parseFilesResponse(const QByteArray &data, const QString &id) {
558     QJsonObject keys = m_download["res"].toObject();
559     QStringList urls;
560     QStringList labels;
561 
562     if(keys["format"].toString() == "json") {
563         QJsonObject res = QJsonDocument::fromJson(data).object();
564 
565         if(keys["downloadUrls"].isObject()) {
566             if(keys["downloadUrls"].toObject()["isObject"].toBool(false)) {
567                 QJsonObject list = objectGetValue(res, "downloadUrls.key").toObject();
568                 for (const auto &key : list.keys()) {
569                     QJsonObject urlItem = list[key].toObject();
570                     QString format = objectGetString(urlItem, "downloadUrls.format", id, key);
571                     //This ugly check is only for the complicated archive.org api to avoid a long file list for videos caused by thumbs for each frame and metafiles
572                     if(m_type == ProviderModel::VIDEO && m_homepage == "https://archive.org" && format != QLatin1String("Animated GIF") && format != QLatin1String("Metadata")
573                             && format != QLatin1String("Archive BitTorrent") && format != QLatin1String("Thumbnail") && format != QLatin1String("JSON")
574                             && format != QLatin1String("JPEG") && format != QLatin1String("JPEG Thumb") && format != QLatin1String("PNG")
575                             && format != QLatin1String("Video Index")) {
576                         urls << objectGetString(urlItem, "downloadUrls.url", id, key);
577                         labels << objectGetString(urlItem, "downloadUrls.name", id, key);
578                     }
579                 }
580             } else {
581                 for (const auto urlItem : objectGetValue(res, "downloadUrls.key").toArray()) {
582                     urls << objectGetString(urlItem.toObject(), "downloadUrls.url", id);
583                     labels << objectGetString(urlItem.toObject(), "downloadUrls.name", id);
584                 }
585             }
586 
587         } else if(keys["downloadUrl"].isString()){
588             urls << objectGetString(res, "downloadUrl", id);
589         }
590 
591     } else {
592         qCWarning(KDENLIVE_LOG) << "WARNING fetch files: unknown response format: " << keys["format"];
593     }
594     return std::pair<QStringList, QStringList> (urls, labels);
595 }
596