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 §ion : 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