From 060db5c6cbd02e684a0c98c0f045da242b6ab218 Mon Sep 17 00:00:00 2001 From: dec05eba Date: Sat, 9 Oct 2021 14:49:13 +0200 Subject: Add lbry, attempt to fix 4chan posting when captcha is no-op --- src/QuickMedia.cpp | 7 +- src/StringUtils.cpp | 22 +++ src/VideoPlayer.cpp | 2 +- src/plugins/Fourchan.cpp | 2 +- src/plugins/ImageBoard.cpp | 5 + src/plugins/Lbry.cpp | 397 +++++++++++++++++++++++++++++++++++++++++++++ src/plugins/Manganelo.cpp | 2 +- src/plugins/Peertube.cpp | 22 --- src/plugins/Soundcloud.cpp | 32 +--- 9 files changed, 435 insertions(+), 56 deletions(-) create mode 100644 src/plugins/Lbry.cpp (limited to 'src') diff --git a/src/QuickMedia.cpp b/src/QuickMedia.cpp index 435ec3b..65d6159 100644 --- a/src/QuickMedia.cpp +++ b/src/QuickMedia.cpp @@ -10,6 +10,7 @@ #include "../plugins/NyaaSi.hpp" #include "../plugins/Matrix.hpp" #include "../plugins/Soundcloud.hpp" +#include "../plugins/Lbry.hpp" #include "../plugins/FileManager.hpp" #include "../plugins/Pipe.hpp" #include "../plugins/Saucenao.hpp" @@ -78,6 +79,7 @@ static const std::pair valid_plugins[] = { std::make_pair("youtube", "yt_logo_rgb_dark_small.png"), std::make_pair("peertube", "peertube_logo.png"), std::make_pair("soundcloud", "soundcloud_logo.png"), + std::make_pair("lbry", "lbry_logo.png"), std::make_pair("pornhub", "pornhub_logo.png"), std::make_pair("spankbang", "spankbang_logo.png"), std::make_pair("xvideos", "xvideos_logo.png"), @@ -286,7 +288,7 @@ namespace QuickMedia { static void usage() { fprintf(stderr, "usage: quickmedia [plugin] [--no-video] [--dir ] [-e ] [youtube-url]\n"); fprintf(stderr, "OPTIONS:\n"); - fprintf(stderr, " plugin The plugin to use. Should be either launcher, 4chan, manga, manganelo, manganelos, mangatown, mangakatana, mangadex, readm, onimanga, youtube, peertube, soundcloud, nyaa.si, matrix, saucenao, hotexamples, anilist, file-manager, stdin, pornhub, spankbang, xvideos or xhamster\n"); + fprintf(stderr, " plugin The plugin to use. Should be either launcher, 4chan, manga, manganelo, manganelos, mangatown, mangakatana, mangadex, readm, onimanga, youtube, peertube, lbry, soundcloud, nyaa.si, matrix, saucenao, hotexamples, anilist, file-manager, stdin, pornhub, spankbang, xvideos or xhamster\n"); fprintf(stderr, " --no-video Only play audio when playing a video. Disabled by default\n"); fprintf(stderr, " --upscale-images Upscale low-resolution manga pages using waifu2x-ncnn-vulkan. Disabled by default\n"); fprintf(stderr, " --upscale-images-always Upscale manga pages using waifu2x-ncnn-vulkan, no matter what the original image resolution is. Disabled by default\n"); @@ -1046,6 +1048,7 @@ namespace QuickMedia { create_launcher_body_item("4chan", "4chan", resources_root + "icons/4chan_launcher.png"), create_launcher_body_item("AniList", "anilist", resources_root + "images/anilist_logo.png"), create_launcher_body_item("Hot Examples", "hotexamples", ""), + create_launcher_body_item("Lbry", "lbry", "icons/lbry_launcher.png"), create_launcher_body_item("Manga (all)", "manga", ""), create_launcher_body_item("Mangadex", "mangadex", resources_root + "icons/mangadex_launcher.png"), create_launcher_body_item("Mangakatana", "mangakatana", resources_root + "icons/mangakatana_launcher.png"), @@ -1262,6 +1265,8 @@ namespace QuickMedia { } else if(strcmp(plugin_name, "soundcloud") == 0) { tabs.push_back(Tab{create_body(false, true), std::make_unique(this), create_search_bar("Search...", 500)}); no_video = true; + } else if(strcmp(plugin_name, "lbry") == 0) { + tabs.push_back(Tab{create_body(false, true), std::make_unique(this), create_search_bar("Search...", 500)}); } else if(strcmp(plugin_name, "matrix") == 0) { assert(!matrix); if(create_directory_recursive(get_cache_dir().join("matrix").join("events")) != 0) { diff --git a/src/StringUtils.cpp b/src/StringUtils.cpp index 5706499..494e32f 100644 --- a/src/StringUtils.cpp +++ b/src/StringUtils.cpp @@ -219,4 +219,26 @@ namespace QuickMedia { else return std::to_string(seconds) + " second" + (seconds == 1 ? "" : "s") + " ago"; } + + std::string seconds_to_duration(int seconds) { + seconds = std::max(0, seconds); + + int minutes = seconds / 60; + int hours = minutes / 60; + char buffer[32]; + + if(hours >= 1) { + minutes -= (hours * 60); + seconds -= (hours * 60 * 60); + seconds -= (minutes * 60); + snprintf(buffer, sizeof(buffer), "%02d:%02d:%02d", hours, minutes, seconds); + } else if(minutes >= 1) { + seconds -= (minutes * 60); + snprintf(buffer, sizeof(buffer), "%02d:%02d", minutes, seconds); + } else { + snprintf(buffer, sizeof(buffer), "00:%02d", seconds); + } + + return buffer; + } } \ No newline at end of file diff --git a/src/VideoPlayer.cpp b/src/VideoPlayer.cpp index 047e525..5d2bf3c 100644 --- a/src/VideoPlayer.cpp +++ b/src/VideoPlayer.cpp @@ -229,7 +229,7 @@ namespace QuickMedia { strcpy(ipc_addr.sun_path, ipc_server_path); int flags = fcntl(ipc_socket, F_GETFL, 0); - if(flags != -1) // TODO: Proper error handling + if(flags != -1) fcntl(ipc_socket, F_SETFL, flags | O_NONBLOCK); if(exec_program_async(args.data(), &video_process_id) != 0) { diff --git a/src/plugins/Fourchan.cpp b/src/plugins/Fourchan.cpp index f7c9910..01c8546 100644 --- a/src/plugins/Fourchan.cpp +++ b/src/plugins/Fourchan.cpp @@ -526,7 +526,7 @@ namespace QuickMedia { additional_args.push_back({ "--form-string", "filename=" + file_get_filename(filepath) }); } - if(pass_id.empty()) { + if(pass_id.empty() && !captcha_id.empty()) { additional_args.push_back(CommandArg{"--form-string", "t-challenge=" + captcha_id}); additional_args.push_back(CommandArg{"--form-string", "t-response=" + captcha_solution}); } diff --git a/src/plugins/ImageBoard.cpp b/src/plugins/ImageBoard.cpp index a2ffca7..f813068 100644 --- a/src/plugins/ImageBoard.cpp +++ b/src/plugins/ImageBoard.cpp @@ -1,6 +1,11 @@ #include "../../plugins/ImageBoard.hpp" +#include namespace QuickMedia { + void ImageBoardThreadPage::copy_to_clipboard(const BodyItem *body_item) const { + sf::Clipboard::setString(sf::String::fromUtf8(body_item->get_description().begin(), body_item->get_description().end())); + } + std::unique_ptr ImageBoardThreadPage::create_related_videos_page(Program*) { return nullptr; } diff --git a/src/plugins/Lbry.cpp b/src/plugins/Lbry.cpp new file mode 100644 index 0000000..c35e430 --- /dev/null +++ b/src/plugins/Lbry.cpp @@ -0,0 +1,397 @@ +#include "../../plugins/Lbry.hpp" +#include "../../include/Notification.hpp" +#include "../../include/StringUtils.hpp" +#include "../../include/Theme.hpp" +#include +#include + +// TODO: Images, music, regular files + +namespace QuickMedia { + static void *search_type_video = (void*)0; + static void *search_type_channel = (void*)1; + + static bool handle_error(const Json::Value &json_root, std::string &err_str) { + const Json::Value &error_json = json_root["error"]; + if(!error_json.isObject()) + return false; + + const Json::Value &code_json = error_json["code"]; + const Json::Value &message_json = error_json["message"]; + if(message_json.isString()) + err_str += "message: " + message_json.asString(); + if(code_json.isString()) + err_str += " (code: " + code_json.asString() + ")"; + return true; + } + + static std::shared_ptr resolve_claim_parse_result(const Json::Value &result_json, time_t time_now) { + if(!result_json.isObject()) + return nullptr; + + const Json::Value &canonical_url_json = result_json["canonical_url"]; + const Json::Value &claim_id_json = result_json["claim_id"]; + const Json::Value &value_type_json = result_json["value_type"]; + if(!canonical_url_json.isString() || !claim_id_json.isString() || !value_type_json.isString()) + return nullptr; + + const Json::Value &value_json = result_json["value"]; + if(!value_json.isObject()) + return nullptr; + + const Json::Value &title_json = value_json["title"]; + if(!title_json.isString()) + return nullptr; + + auto body_item = BodyItem::create(title_json.asString()); + + bool is_channel = false; + // TODO: Support other types than stream and channel + if(strcmp(value_type_json.asCString(), "channel") == 0) { + body_item->url = claim_id_json.asString(); + body_item->userdata = search_type_channel; + is_channel = true; + } else if(strcmp(value_type_json.asCString(), "stream") == 0) { + body_item->url = canonical_url_json.asString(); + body_item->userdata = search_type_video; + + // Skip livestreams for now as they are pretty broken on lbry. + // Livestream requests work by doing GET https://api.live.odysee.com/v1/odysee/live/ + // then get stream url with .data.url. If that is missing then there is no livestream going on. What to do then? + // TODO: Add livestreams when lbry fixes them. + const Json::Value &stream_type_json = value_json["stream_type"]; + if(!stream_type_json.isString() || strcmp(stream_type_json.asCString(), "video") != 0) + return nullptr; + } + body_item->thumbnail_size = { 177, 100 }; + + const Json::Value &thumbnail_json = value_json["thumbnail"]; + if(thumbnail_json.isObject()) { + const Json::Value &url_json = thumbnail_json["url"]; + if(url_json.isString()) { + if(strstr(url_json.asCString(), "ytimg.com")) + body_item->thumbnail_url = url_json.asString(); + else + body_item->thumbnail_url = url_json.asString() + "?quality=85&width=177&height=100"; + } + } + + std::string description; + + if(is_channel) { + const Json::Value &meta_json = result_json["meta"]; + if(meta_json.isObject()) { + const Json::Value &claims_in_channel_json = meta_json["claims_in_channel"]; + if(claims_in_channel_json.isInt()) { + const int claims_in_channel = claims_in_channel_json.asInt(); + description = std::to_string(claims_in_channel) + " upload" + (claims_in_channel == 0 ? "" : "s"); + } + } + + const Json::Value &name_json = result_json["name"]; + if(name_json.isString()) { + if(!description.empty()) + description += '\n'; + description += name_json.asString(); + } + } else { + const Json::Value ×tamp_json = result_json["timestamp"]; + if(timestamp_json.isInt64()) + description = seconds_to_relative_time_str(time_now - timestamp_json.asInt64()); + } + + const Json::Value &video_json = value_json["video"]; + if(video_json.isObject()) { + const Json::Value duration_json = video_json["duration"]; + if(duration_json.isInt()) { + if(!description.empty()) + description += '\n'; + description += seconds_to_duration(duration_json.asInt()); + } + } + + const Json::Value &signing_channel_json = result_json["signing_channel"]; + if(signing_channel_json.isObject()) { + const Json::Value &name_json = signing_channel_json["name"]; + if(name_json.isString()) { + if(!description.empty()) + description += '\n'; + description += name_json.asString(); + } + } + + if(!description.empty()) { + body_item->set_description(std::move(description)); + body_item->set_description_color(get_theme().faded_text_color); + } + + return body_item; + } + + static PluginResult resolve_claims(Page *page, const Json::Value &request_json, BodyItems &result_items) { + if(!request_json.isObject()) + return PluginResult::ERR; + + const Json::Value &method_json = request_json["method"]; + if(!method_json.isString()) + return PluginResult::ERR; + + std::string url = "https://api.na-backend.odysee.com/api/v1/proxy?m=" + method_json.asString(); + + Json::StreamWriterBuilder json_builder; + json_builder["commentStyle"] = "None"; + json_builder["indentation"] = ""; + + std::vector additional_args = { + { "-X", "POST" }, + { "-H", "content-type: application/json" }, + { "--data-binary", Json::writeString(json_builder, request_json) } + }; + + Json::Value json_root; + DownloadResult download_result = page->download_json(json_root, url, std::move(additional_args), true); + if(download_result != DownloadResult::OK) return download_result_to_plugin_result(download_result); + + if(!json_root.isObject()) + return PluginResult::ERR; + + std::string err_str; + if(handle_error(json_root, err_str)) { + show_notification("QuickMedia", "Lbry search failed, error: " + err_str, Urgency::CRITICAL); + return PluginResult::ERR; + } + + const Json::Value &result_json = json_root["result"]; + if(!result_json.isObject()) + return PluginResult::ERR; + + const time_t time_now = time(nullptr); + const Json::Value &items_json = result_json["items"]; + if(items_json.isArray()) { + // Channel search + for(const Json::Value &result_json : items_json) { + auto body_item = resolve_claim_parse_result(result_json, time_now); + if(body_item) + result_items.push_back(std::move(body_item)); + } + } else { + // Global search + for(Json::Value::const_iterator it = result_json.begin(); it != result_json.end(); ++it) { + auto body_item = resolve_claim_parse_result(*it, time_now); + if(body_item) + result_items.push_back(std::move(body_item)); + } + } + + return PluginResult::OK; + } + + SearchResult LbrySearchPage::search(const std::string &str, BodyItems &result_items) { + return plugin_result_to_search_result(get_page(str, 0, result_items)); + } + + PluginResult LbrySearchPage::get_page(const std::string &str, int page, BodyItems &result_items) { + if(str.empty()) + return PluginResult::OK; + + // TODO: Support other types than stream and channel + std::string url = "https://lighthouse.odysee.com/search?s=" + url_param_encode(str) + "&size=20&from=" + std::to_string(page * 20) + "&nsfw=false&claimType=stream,channel"; + if(!channel_id.empty()) + url += "&channel_id=" + channel_id; + + Json::Value json_root; + DownloadResult download_result = download_json(json_root, url, {}, true); + if(download_result != DownloadResult::OK) return download_result_to_plugin_result(download_result); + + if(!json_root.isArray()) + return PluginResult::ERR; + + Json::Value request_json(Json::objectValue); + request_json["id"] = (int64_t)time(nullptr) * 1000; + request_json["jsonrpc"] = "2.0"; + request_json["method"] = "resolve"; + + Json::Value request_params_json(Json::objectValue); + request_params_json["include_purchase_receipt"] = true; + + Json::Value urls_json(Json::arrayValue); + + for(const Json::Value &claim_json : json_root) { + if(!claim_json.isObject()) + continue; + + const Json::Value &claim_id_json = claim_json["claimId"]; + const Json::Value &name_json = claim_json["name"]; + if(!claim_id_json.isString() || !name_json.isString()) + continue; + + urls_json.append("lbry://" + name_json.asString() + "#" + claim_id_json.asString()); + } + + request_params_json["urls"] = std::move(urls_json); + request_json["params"] = std::move(request_params_json); + return resolve_claims(this, request_json, result_items); + } + + PluginResult LbrySearchPage::submit(const std::string &title, const std::string &url, std::vector &result_tabs) { + if(submit_body_item->userdata == search_type_video) + result_tabs.push_back(Tab{ nullptr, std::make_unique(program, title, url), nullptr }); + else if(submit_body_item->userdata == search_type_channel) + result_tabs.push_back(Tab{ create_body(false, true), std::make_unique(program, title, url), create_search_bar("Search...", 500) }); + return PluginResult::OK; + } + + SearchResult LbryChannelPage::search(const std::string &str, BodyItems &result_items) { + return plugin_result_to_search_result(get_page(str, 0, result_items)); + } + + PluginResult LbryChannelPage::get_page(const std::string &str, int page, BodyItems &result_items) { + if(!str.empty()) + return search_page.get_page(str, page, result_items); + + const int64_t time_now = time(nullptr); + + Json::Value channel_ids_json(Json::arrayValue); + channel_ids_json.append(channel_id); + + Json::Value claim_type_json(Json::arrayValue); + claim_type_json.append("stream"); + claim_type_json.append("repost"); + + Json::Value not_tags_json(Json::arrayValue); + not_tags_json.append("porn"); + not_tags_json.append("porno"); + not_tags_json.append("nsfw"); + not_tags_json.append("mature"); + not_tags_json.append("xxx"); + not_tags_json.append("sex"); + not_tags_json.append("creampie"); + not_tags_json.append("blowjob"); + not_tags_json.append("handjob"); + not_tags_json.append("vagina"); + not_tags_json.append("boobs"); + not_tags_json.append("big boobs"); + not_tags_json.append("big dick"); + not_tags_json.append("pussy"); + not_tags_json.append("cumshot"); + not_tags_json.append("anal"); + not_tags_json.append("hard fucking"); + not_tags_json.append("ass"); + not_tags_json.append("fuck"); + not_tags_json.append("hentai"); + + Json::Value order_by_json(Json::arrayValue); + order_by_json.append("release_time"); + + Json::Value request_params_json(Json::objectValue); + request_params_json["channel_ids"] = std::move(channel_ids_json); + request_params_json["claim_type"] = std::move(claim_type_json); + request_params_json["fee_amount"] = ">=0"; + request_params_json["has_source"] = true; + request_params_json["include_purchase_receipt"] = true; + request_params_json["no_totals"] = true; + request_params_json["not_tags"] = std::move(not_tags_json); + request_params_json["order_by"] = std::move(order_by_json); + request_params_json["page"] = 1 + page; + request_params_json["page_size"] = 20; + request_params_json["release_time"] = "<" + std::to_string(time_now); + + Json::Value request_json(Json::objectValue); + request_json["id"] = time_now * 1000; + request_json["jsonrpc"] = "2.0"; + request_json["method"] = "claim_search"; + request_json["params"] = std::move(request_params_json); + return resolve_claims(this, request_json, result_items); + } + + PluginResult LbryChannelPage::submit(const std::string &title, const std::string &url, std::vector &result_tabs) { + result_tabs.push_back(Tab{ nullptr, std::make_unique(program, title, url), nullptr }); + return PluginResult::OK; + } + + PluginResult LbryChannelPage::lazy_fetch(BodyItems &result_items) { + return get_page("", 0, result_items); + } + + static PluginResult video_get_stream_url(Page *page, const std::string &video_url, std::string &streaming_url, std::string &err_str) { + std::string url = "https://api.na-backend.odysee.com/api/v1/proxy?m=resolve"; + + Json::Value request_params_json(Json::objectValue); + request_params_json["save_file"] = false; + request_params_json["uri"] = video_url; + + Json::Value request_json(Json::objectValue); + request_json["id"] = (int64_t)time(nullptr) * 1000; + request_json["jsonrpc"] = "2.0"; + request_json["method"] = "get"; + request_json["params"] = std::move(request_params_json); + + Json::StreamWriterBuilder json_builder; + json_builder["commentStyle"] = "None"; + json_builder["indentation"] = ""; + + std::vector additional_args = { + { "-X", "POST" }, + { "-H", "content-type: application/json" }, + { "--data-binary", Json::writeString(json_builder, request_json) } + }; + + Json::Value json_root; + DownloadResult download_result = page->download_json(json_root, url, std::move(additional_args), true); + if(download_result != DownloadResult::OK) return download_result_to_plugin_result(download_result); + + if(!json_root.isObject()) + return PluginResult::ERR; + + err_str.clear(); + if(handle_error(json_root, err_str)) + return PluginResult::ERR; + + const Json::Value &result_json = json_root["result"]; + if(!result_json.isObject()) + return PluginResult::ERR; + + const Json::Value &streaming_url_json = result_json["streaming_url"]; + if(!streaming_url_json.isString()) + return PluginResult::ERR; + + streaming_url = streaming_url_json.asString(); + return PluginResult::OK; + } + + std::unique_ptr LbryVideoPage::create_comments_page(Program*) { + return nullptr; + } + + std::unique_ptr LbryVideoPage::create_related_videos_page(Program*) { + return nullptr; + } + + std::unique_ptr LbryVideoPage::create_channels_page(Program*, const std::string&) { + return nullptr; + } + + // TODO: Support |max_height|. This can be done by gettin video source hash and checking for sd_hash and then resolution. + // If max_height is below max resolution height then choose the sd_hash version (replace hash in video stream with sd hash for the lower quality version) + std::string LbryVideoPage::get_download_url(int max_height) { + bool has_embedded_audio; + std::string ext; + return get_video_url(max_height, has_embedded_audio, ext); + } + + std::string LbryVideoPage::get_video_url(int max_height, bool &has_embedded_audio, std::string &ext) { + has_embedded_audio = true; + ext = ".mp4"; // TODO: Check if this is always correct + return streaming_url; + } + + std::string LbryVideoPage::get_audio_url(std::string&) { + return ""; + } + + PluginResult LbryVideoPage::load(std::string &title, std::string&, std::vector&, std::string &err_str) { + streaming_url.clear(); + title = this->title; + return video_get_stream_url(this, url, streaming_url, err_str); + } +} \ No newline at end of file diff --git a/src/plugins/Manganelo.cpp b/src/plugins/Manganelo.cpp index df86207..b1e61c4 100644 --- a/src/plugins/Manganelo.cpp +++ b/src/plugins/Manganelo.cpp @@ -109,7 +109,7 @@ namespace QuickMedia { std::string url = "https://manganelo.com/getstorysearchjson"; std::string search_term = "searchword="; search_term += url_param_encode(str); - CommandArg data_arg = { "--data", std::move(search_term) }; + CommandArg data_arg = { "--form-string", std::move(search_term) }; Json::Value json_root; DownloadResult result = download_json(json_root, url, {data_arg}, true); diff --git a/src/plugins/Peertube.cpp b/src/plugins/Peertube.cpp index 3b74871..b83daf5 100644 --- a/src/plugins/Peertube.cpp +++ b/src/plugins/Peertube.cpp @@ -56,28 +56,6 @@ namespace QuickMedia { return plugin_result_to_search_result(get_page(str, 0, result_items)); } - static std::string seconds_to_duration(int seconds) { - seconds = std::max(0, seconds); - - int minutes = seconds / 60; - int hours = minutes / 60; - char buffer[32]; - - if(hours >= 1) { - minutes -= (hours * 60); - seconds -= (hours * 60 * 60); - seconds -= (minutes * 60); - snprintf(buffer, sizeof(buffer), "%02d:%02d:%02d", hours, minutes, seconds); - } else if(minutes >= 1) { - seconds -= (minutes * 60); - snprintf(buffer, sizeof(buffer), "%02d:%02d", minutes, seconds); - } else { - snprintf(buffer, sizeof(buffer), "0:%02d", seconds); - } - - return buffer; - } - // TODO: Support remote content static std::shared_ptr search_data_to_body_item(const Json::Value &data_json, const std::string &server, PeertubeSearchPage::SearchType search_type) { if(!data_json.isObject()) diff --git a/src/plugins/Soundcloud.cpp b/src/plugins/Soundcloud.cpp index d54060d..8885dfb 100644 --- a/src/plugins/Soundcloud.cpp +++ b/src/plugins/Soundcloud.cpp @@ -27,42 +27,14 @@ namespace QuickMedia { return ""; } - static std::string duration_to_descriptive_string(int64_t milliseconds) { - time_t seconds = milliseconds / 1000; - time_t minutes = seconds / 60; - time_t hours = minutes / 60; - - std::string str; - if(hours >= 1) { - str = std::to_string(hours) + " hour" + (hours == 1 ? "" : "s"); - minutes -= (hours * 60); - seconds -= (hours * 60 * 60); - } - - if(minutes >= 1) { - if(!str.empty()) - str += ", "; - str += std::to_string(minutes) + " minute" + (minutes == 1 ? "" : "s"); - seconds -= (minutes * 60); - } - - if(!str.empty() || seconds > 0) { - if(!str.empty()) - str += ", "; - str += std::to_string(seconds) + " second" + (seconds == 1 ? "" : "s"); - } - - return str; - } - static std::string collection_item_get_duration(const Json::Value &item_json) { const Json::Value &full_duration_json = item_json["full_duration"]; if(full_duration_json.isInt64()) - return duration_to_descriptive_string(full_duration_json.asInt64()); + return seconds_to_duration(full_duration_json.asInt64()); const Json::Value &duration_json = item_json["duration"]; if(duration_json.isInt64()) - return duration_to_descriptive_string(duration_json.asInt64()); + return seconds_to_duration(duration_json.asInt64()); return ""; } -- cgit v1.2.3