From 76ef34393aa72230a3490ecf7b06647ede1448da Mon Sep 17 00:00:00 2001 From: dec05eba Date: Thu, 9 Sep 2021 11:35:47 +0200 Subject: Add initial peertube support --- src/plugins/Peertube.cpp | 421 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 421 insertions(+) create mode 100644 src/plugins/Peertube.cpp (limited to 'src/plugins/Peertube.cpp') diff --git a/src/plugins/Peertube.cpp b/src/plugins/Peertube.cpp new file mode 100644 index 0000000..dacd2d0 --- /dev/null +++ b/src/plugins/Peertube.cpp @@ -0,0 +1,421 @@ +#include "../../plugins/Peertube.hpp" +#include "../../include/Theme.hpp" +#include "../../include/Notification.hpp" +#include "../../include/Utils.hpp" +#include "../../include/StringUtils.hpp" + +namespace QuickMedia { + static const char* search_type_to_string(PeertubeSearchPage::SearchType search_type) { + switch(search_type) { + case PeertubeSearchPage::SearchType::VIDEO_CHANNELS: return "video-channels"; + case PeertubeSearchPage::SearchType::VIDEO_PLAYLISTS: return "video-playlists"; + case PeertubeSearchPage::SearchType::VIDEOS: return "videos"; + } + return ""; + } + + PluginResult PeertubeInstanceSelectionPage::submit(const std::string&, const std::string &url, std::vector &result_tabs) { + result_tabs.push_back(Tab{create_body(false, true), std::make_unique(program, url), create_search_bar("Search...", 350)}); + return PluginResult::OK; + } + + static std::shared_ptr create_instance_selection_item(const std::string &title, const std::string &url) { + auto body_item = BodyItem::create(title); + body_item->url = url; + return body_item; + } + + PluginResult PeertubeInstanceSelectionPage::lazy_fetch(BodyItems &result_items) { + result_items.push_back(create_instance_selection_item("tube.midov.pl", "https://tube.midov.pl")); + result_items.push_back(create_instance_selection_item("videos.lukesmith.xyz", "https://videos.lukesmith.xyz")); + return PluginResult::OK; + } + + PeertubeSearchPage::PeertubeSearchPage(Program *program, const std::string &server_) : LazyFetchPage(program), server(server_) { + if(!server.empty() && server.back() == '/') + server.pop_back(); + } + + SearchResult PeertubeSearchPage::search(const std::string &str, BodyItems &result_items) { + 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); + 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()) + return nullptr; + + const Json::Value &name_json = data_json["name"]; + const Json::Value &host_json = data_json["host"]; + const Json::Value &display_name_json = data_json["displayName"]; + const Json::Value &uuid_json = data_json["uuid"]; + const Json::Value &short_uuid_json = data_json["shortUUID"]; + + std::string name_str; + if(name_json.isString()) + name_str = name_json.asString(); + + std::string display_name_str; + if(display_name_json.isString()) + display_name_str = display_name_json.asString(); + else + display_name_str = name_str; + + auto body_item = BodyItem::create(std::move(display_name_str)); + body_item->userdata = (void*)search_type; + + if(search_type == PeertubeSearchPage::SearchType::VIDEO_PLAYLISTS) { + if(uuid_json.isString()) + body_item->url = uuid_json.asString(); + } else { + if(short_uuid_json.isString()) + body_item->url = short_uuid_json.asString(); + else if(name_json.isString() && host_json.isString()) + body_item->url = name_str + "@" + host_json.asString(); + else + return nullptr; + } + + std::string description; + const Json::Value &videos_length_json = data_json["videosLength"]; + if(videos_length_json.isInt()) + description += std::to_string(videos_length_json.asInt()) + " video" + (videos_length_json.asInt() == 1 ? "" : "s"); + + const Json::Value &views_json = data_json["views"]; + if(views_json.isInt()) + description += std::to_string(views_json.asInt()) + " view" + (views_json.asInt() == 1 ? "" : "s"); + + const Json::Value published_at_json = data_json["publishedAt"]; + if(published_at_json.isString()) { + if(!description.empty()) + description += " • "; + const time_t unix_time = iso_utc_to_unix_time(published_at_json.asCString()); + description += "Published " + seconds_to_relative_time_str(time(nullptr) - unix_time); + } + + const Json::Value updated_at_json = data_json["updatedAt"]; + if(!published_at_json.isString() && updated_at_json.isString()) { + if(!description.empty()) + description += " • "; + const time_t unix_time = iso_utc_to_unix_time(updated_at_json.asCString()); + description += "Updated " + seconds_to_relative_time_str(time(nullptr) - unix_time); + } + + const Json::Value &duration_json = data_json["duration"]; + if(duration_json.isInt()) { + if(!description.empty()) + description += '\n'; + description += seconds_to_duration(duration_json.asInt()); + } + + for(const char *field_name : { "account", "videoChannel", "ownerAccount" }) { + const Json::Value &account_json = data_json[field_name]; + if(account_json.isObject()) { + const Json::Value &channel_name_json = account_json["name"]; + if(channel_name_json.isString()) { + if(!description.empty()) + description += '\n'; + + description += channel_name_json.asString(); + + const Json::Value &account_host_json = account_json["host"]; + if(account_host_json.isString()) + description += "@" + account_host_json.asString(); + + break; + } + } + } + + if(!description.empty()) { + body_item->set_description(std::move(description)); + body_item->set_description_color(get_theme().faded_text_color); + } + + const Json::Value &owner_account_json = data_json["ownerAccount"]; + if(owner_account_json.isObject()) { + const Json::Value &avatar_json = owner_account_json["avatar"]; + if(avatar_json.isObject()) { + const Json::Value &path_json = avatar_json["path"]; + if(path_json.isString()) { + body_item->thumbnail_url = server + path_json.asString(); + body_item->thumbnail_size = { 130, 130 }; + } + } + } + + const Json::Value &thumbnail_path_json = data_json["thumbnailPath"]; + if(thumbnail_path_json.isString()) { + body_item->thumbnail_url = server + thumbnail_path_json.asString(); + body_item->thumbnail_size = { 280, 153 }; + } + + if(search_type == PeertubeSearchPage::SearchType::VIDEO_CHANNELS && body_item->thumbnail_url.empty()) { + body_item->thumbnail_url = server + "/client/assets/images/default-avatar-videochannel.png"; + body_item->thumbnail_size = { 130, 130 }; + } + + return body_item; + } + + PluginResult PeertubeSearchPage::get_page(const std::string &str, int page, BodyItems &result_items) { + if(str.empty()) + return get_local_videos(page, result_items); + + // TODO: Parallel + PluginResult result; + + result = get_page_by_type(SearchType::VIDEO_CHANNELS, str, page, 10, result_items); + if(result != PluginResult::OK) return result; + + result = get_page_by_type(SearchType::VIDEO_PLAYLISTS, str, page, 10, result_items); + if(result != PluginResult::OK) return result; + + result = get_page_by_type(SearchType::VIDEOS, str, page, 10, result_items); + if(result != PluginResult::OK) return result; + + return PluginResult::OK; + } + + // Returns true if the error was handled (if there was an error) + static bool handle_error(const Json::Value &json_root, std::string &err_str) { + if(!json_root.isObject()) + return false; + + const Json::Value &status_json = json_root["status"]; + const Json::Value &detail_json = json_root["detail"]; + if(status_json.isInt() && detail_json.isString()) { + err_str = detail_json.asString(); + return true; + } + + return false; + } + + static PluginResult videos_request(Page *page, const std::string &url, const std::string &server, PeertubeSearchPage::SearchType search_type, BodyItems &result_items) { + Json::Value json_root; + std::string err_msg; + DownloadResult result = page->download_json(json_root, url, {}, true, &err_msg); + if(result != DownloadResult::OK) return download_result_to_plugin_result(result); + + if(!json_root.isObject()) + return PluginResult::ERR; + + std::string err_str; + if(handle_error(json_root, err_str)) { + show_notification("QuickMedia", "Peertube server returned an error: " + err_str, Urgency::CRITICAL); + return PluginResult::ERR; + } + + const Json::Value &data_array_json = json_root["data"]; + if(data_array_json.isArray()) { + for(const Json::Value &data_json : data_array_json) { + const Json::Value &video_json = data_json["video"]; + auto body_item = search_data_to_body_item(video_json.isObject() ? video_json : data_json, server, search_type); + if(body_item) + result_items.push_back(std::move(body_item)); + } + } + + return PluginResult::OK; + } + + PluginResult PeertubeSearchPage::get_local_videos(int page, BodyItems &result_items) { + char url[2048]; + snprintf(url, sizeof(url), "%s/api/v1/videos/?start=%d&count=%d&sort=-publishedAt&filter=local&skipCount=true", server.c_str(), page * 20, 20); + return videos_request(this, url, server, SearchType::VIDEOS, result_items); + } + + PluginResult PeertubeSearchPage::get_page_by_type(SearchType search_type, const std::string &str, int page, int count, BodyItems &result_items) { + char url[2048]; + snprintf(url, sizeof(url), "%s/api/v1/search/%s?start=%d&count=%d&search=%s&searchTarget=local", server.c_str(), search_type_to_string(search_type), page * count, count, url_param_encode(str).c_str()); + return videos_request(this, url, server, search_type, result_items); + } + + PluginResult PeertubeSearchPage::submit(const std::string &title, const std::string &url, std::vector &result_tabs) { + const SearchType search_type = (SearchType)(uintptr_t)submit_body_item->userdata; + if(search_type == SearchType::VIDEO_CHANNELS) { + result_tabs.push_back(Tab{ create_body(false, true), std::make_unique(program, server, title, url), nullptr }); + } else if(search_type == SearchType::VIDEO_PLAYLISTS) { + result_tabs.push_back(Tab{ create_body(false, true), std::make_unique(program, server, title, url), nullptr }); + } else if(search_type == SearchType::VIDEOS) { + result_tabs.push_back(Tab{ nullptr, std::make_unique(program, server, url, false), nullptr }); + } + return PluginResult::OK; + } + + PluginResult PeertubeSearchPage::lazy_fetch(BodyItems &result_items) { + return get_local_videos(0, result_items); + } + + PluginResult PeertubeChannelPage::get_page(const std::string&, int page, BodyItems &result_items) { + char url[2048]; + snprintf(url, sizeof(url), "%s/api/v1/video-channels/%s/videos?start=%d&count=%d&sort=-publishedAt", server.c_str(), name.c_str(), page * 20, 20); + return videos_request(this, url, server, PeertubeSearchPage::SearchType::VIDEOS, result_items); + } + + PluginResult PeertubeChannelPage::submit(const std::string&, const std::string &url, std::vector &result_tabs) { + result_tabs.push_back(Tab{ nullptr, std::make_unique(program, server, url, false), nullptr }); + return PluginResult::OK; + } + + PluginResult PeertubeChannelPage::lazy_fetch(BodyItems &result_items) { + return get_page("", 0, result_items); + } + + PluginResult PeertubePlaylistPage::get_page(const std::string&, int page, BodyItems &result_items) { + char url[2048]; + snprintf(url, sizeof(url), "%s/api/v1/video-playlists/%s/videos?start=%d&count=%d", server.c_str(), uuid.c_str(), page * 20, 20); + return videos_request(this, url, server, PeertubeSearchPage::SearchType::VIDEOS, result_items); + } + + PluginResult PeertubePlaylistPage::submit(const std::string&, const std::string &url, std::vector &result_tabs) { + result_tabs.push_back(Tab{ nullptr, std::make_unique(program, server, url, true), nullptr }); + return PluginResult::OK; + } + + PluginResult PeertubePlaylistPage::lazy_fetch(BodyItems &result_items) { + return get_page("", 0, result_items); + } + + std::unique_ptr PeertubeVideoPage::create_comments_page(Program*) { + return nullptr; + } + + std::unique_ptr PeertubeVideoPage::create_related_videos_page(Program*) { + return nullptr; + } + + std::unique_ptr PeertubeVideoPage::create_channels_page(Program*, const std::string&) { + return nullptr; + } + + static std::string get_ext_from_url(const std::string &url) { + const size_t dot_index = url.rfind('.'); + if(dot_index == std::string::npos) + return ""; + return url.substr(dot_index); + } + + std::string PeertubeVideoPage::get_video_url(int max_height, bool &has_embedded_audio, std::string &ext) { + has_embedded_audio = true; + + for(const PeertubeVideoPage::VideoSource &video_source : video_sources) { + if(video_source.resolution <= max_height) { + ext = get_ext_from_url(video_source.url); + return video_source.url; + } + } + + if(!video_sources.empty()) { + ext = get_ext_from_url(video_sources.front().url); + return video_sources.front().url; + } + + return ""; + } + + std::string PeertubeVideoPage::get_audio_url(std::string&) { + // TODO: Return when audio only mode is enabled + return ""; + } + + // TODO: Download video using torrent and seed it to at least 2x ratio + static bool files_get_sources(const Json::Value &files_json, std::vector &video_sources) { + if(!files_json.isArray()) + return false; + + for(const Json::Value &file_json : files_json) { + if(!file_json.isObject()) + continue; + + const Json::Value &file_download_url_json = file_json["fileDownloadUrl"]; + if(!file_download_url_json.isString()) + continue; + + PeertubeVideoPage::VideoSource video_source; + video_source.url = file_download_url_json.asString(); + video_source.resolution = 0; + + const Json::Value &resolution_json = file_json["resolution"]; + if(resolution_json.isObject()) { + const Json::Value &id_json = resolution_json["id"]; + if(id_json.isInt()) + video_source.resolution = id_json.asInt(); + } + + video_sources.push_back(std::move(video_source)); + } + + // TODO: Also sort by fps + std::sort(video_sources.begin(), video_sources.end(), [](const PeertubeVideoPage::VideoSource &source1, const PeertubeVideoPage::VideoSource &source2) { + return source1.resolution > source2.resolution; + }); + + return !video_sources.empty(); + } + + // TODO: Media chapters + PluginResult PeertubeVideoPage::load(std::string &title, std::string &channel_url, std::vector&, std::string &err_str) { + Json::Value json_root; + std::string err_msg; + DownloadResult download_result = download_json(json_root, server + "/api/v1/videos/" + url, {}, true, &err_msg); + if(download_result != DownloadResult::OK) return download_result_to_plugin_result(download_result); + + if(!json_root.isObject()) + return PluginResult::ERR; + + if(handle_error(json_root, err_str)) + return PluginResult::ERR; + + const Json::Value &name_json = json_root["name"]; + if(name_json.isString()) + title = name_json.asString(); + + const Json::Value &channel_json = json_root["channel"]; + if(channel_json.isObject()) { + const Json::Value &channel_url_json = channel_json["url"]; + if(channel_url_json.isString()) + channel_url = channel_url_json.asString(); + } + + video_sources.clear(); + if(!files_get_sources(json_root["files"], video_sources)) { + const Json::Value &streaming_playlists_json = json_root["streamingPlaylists"]; + if(!streaming_playlists_json.isArray()) + return PluginResult::ERR; + + for(const Json::Value &streaming_playlist_json : streaming_playlists_json) { + if(!streaming_playlist_json.isObject()) + continue; + files_get_sources(streaming_playlist_json["files"], video_sources); + } + + if(video_sources.empty()) + return PluginResult::ERR; + } + + return PluginResult::OK; + } +} \ No newline at end of file -- cgit v1.2.3