From d9cb6885ab741ba69a966109cb05e26692143ce0 Mon Sep 17 00:00:00 2001 From: dec05eba Date: Fri, 2 Oct 2020 16:27:59 +0200 Subject: Matrix: add video/regular file upload --- README.md | 3 +- TODO | 4 +- include/FileAnalyzer.hpp | 57 +++++++++++ include/Path.hpp | 14 ++- plugins/Matrix.hpp | 26 +++-- src/AsyncImageLoader.cpp | 7 +- src/Body.cpp | 4 + src/FileAnalyzer.cpp | 227 ++++++++++++++++++++++++++++++++++++++++++++ src/ImageViewer.cpp | 2 + src/QuickMedia.cpp | 42 ++++++--- src/main.cpp | 1 + src/plugins/Matrix.cpp | 240 +++++++++++++++++++++++++++++++++++------------ 12 files changed, 534 insertions(+), 93 deletions(-) create mode 100644 include/FileAnalyzer.hpp create mode 100644 src/FileAnalyzer.cpp diff --git a/README.md b/README.md index 7d65bd0..fd0ece8 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,8 @@ See project.conf \[dependencies]. `torsocks` needs to be installed when using the `--tor` option.\ [automedia](https://git.dec05eba.com/AutoMedia/) needs to be installed when tracking manga with `Ctrl + T`.\ `waifu2x-ncnn-vulkan` needs to be installed when using the `--upscale-images` option.\ -`xdg-utils` which provides `xdg-open` needs to be installed when downloading torrents with `nyaa.si` plugin. +`xdg-utils` which provides `xdg-open` needs to be installed when downloading torrents with `nyaa.si` plugin.\ +`ffmpeg (and ffprobe)` to upload videos with thumbnails on matrix. # Screenshots ## Youtube search ![](https://www.dec05eba.com/images/youtube.jpg) diff --git a/TODO b/TODO index f1f8f0b..7d2fb94 100644 --- a/TODO +++ b/TODO @@ -69,4 +69,6 @@ Scroll to bottom when receiving a new message even if the selected message is no Add ".." directory to file-manager, to go up one directory. Also add a tab for common directories and recently accessed files/directories (the directories would be the directory of used files). Provide a way to go to the first unread message in matrix and also show a marker in the body (maybe a red line?) where the first unread message is. Sort matrix messages by timestamp. This may be needed to make notification messages show properly in the timeline? -Load already downloaded images/thumbnails in a separate thread, to instantly load them instead of waiting for new downloads... \ No newline at end of file +Load already downloaded images/thumbnails in a separate thread, to instantly load them instead of waiting for new downloads... +Make text that mentions us red in matrix. +Allow scrolling body item. A body item can be long and we wont be able to read all of it otherwise (such as a message on matrix). Pressing up/down should scroll such a large body item rather than moving to another one. \ No newline at end of file diff --git a/include/FileAnalyzer.hpp b/include/FileAnalyzer.hpp new file mode 100644 index 0000000..be0cc25 --- /dev/null +++ b/include/FileAnalyzer.hpp @@ -0,0 +1,57 @@ +#pragma once + +#include +#include +#include + +namespace QuickMedia { + struct Dimensions { + int width; + int height; + }; + + enum class ContentType { + UNKNOWN, + VIDEO_AVI, + VIDEO_MP4, + VIDEO_WEBM, + AUDIO_BASIC, + AUDIO_AIFF, + AUDIO_MPEG, + AUDIO_MIDI, + AUDIO_WAVE, + AUDIO_FLAC, + AUDIO_OPUS, + IMAGE_JPEG, + IMAGE_PNG, + IMAGE_GIF, + IMAGE_BMP, + IMAGE_WEBP + }; + + bool is_content_type_video(ContentType content_type); + bool is_content_type_audio(ContentType content_type); + bool is_content_type_image(ContentType content_type); + const char* content_type_to_string(ContentType content_type); + + bool video_get_first_frame(const char *filepath, const char *destination_path); + + class FileAnalyzer { + public: + FileAnalyzer(); + bool load_file(const char *filepath); + ContentType get_content_type() const; + size_t get_file_size() const; + std::optional get_dimensions() const; + std::optional get_duration_seconds() const; + private: + FileAnalyzer(FileAnalyzer&) = delete; + FileAnalyzer& operator=(FileAnalyzer&) = delete; + private: + ContentType content_type; + size_t file_size; + std::optional dimensions; + std::optional duration_seconds; + bool loaded; + }; +} \ No newline at end of file diff --git a/include/Path.hpp b/include/Path.hpp index bdc31c1..95a5d23 100644 --- a/include/Path.hpp +++ b/include/Path.hpp @@ -26,12 +26,20 @@ namespace QuickMedia { return *this; } + const char* filename() const { + size_t index = data.rfind('/'); + if(index == std::string::npos) + return "/"; + return data.c_str() + index + 1; + } + // Returns empty string if no extension const char* ext() const { + size_t slash_index = data.rfind('/'); size_t index = data.rfind('.'); - if(index == std::string::npos) - return ""; - return data.c_str() + index; + if(index != std::string::npos && (slash_index == std::string::npos || index > slash_index)) + return data.c_str() + index; + return ""; } std::string data; diff --git a/plugins/Matrix.hpp b/plugins/Matrix.hpp index 36b8072..bbf35db 100644 --- a/plugins/Matrix.hpp +++ b/plugins/Matrix.hpp @@ -1,5 +1,6 @@ #pragma once +#include "../include/FileAnalyzer.hpp" #include "Plugin.hpp" #include #include @@ -31,13 +32,6 @@ namespace QuickMedia { MessageType type; }; - struct MessageInfo { - int size = 0; - int w = 0; - int h = 0; - const char* mimetype = nullptr; - }; - struct RoomData { std::string id; // Each room has its own list of user data, even if multiple rooms has the same user @@ -59,6 +53,14 @@ namespace QuickMedia { AFTER }; + struct UploadInfo { + ContentType content_type; + size_t file_size; + std::optional dimensions; + std::optional duration_seconds; + std::string content_uri; + }; + using RoomSyncMessages = std::unordered_map>>; class Matrix : public Plugin { @@ -81,14 +83,13 @@ namespace QuickMedia { // |url| should only be set when uploading media. // TODO: Make api better. - PluginResult post_message(const std::string &room_id, const std::string &body, const std::string &url = "", MessageType msgtype = MessageType::TEXT, MessageInfo *info = nullptr); + PluginResult post_message(const std::string &room_id, const std::string &body, const std::optional &file_info, const std::optional &thumbnail_info); // |relates_to| is from |BodyItem.userdata| and is of type |Message*| PluginResult post_reply(const std::string &room_id, const std::string &body, void *relates_to); // |relates_to| is from |BodyItem.userdata| and is of type |Message*| PluginResult post_edit(const std::string &room_id, const std::string &body, void *relates_to); - // TODO: Make this work for all image types and videos and regular files - PluginResult post_file(const std::string &room_id, const std::string &filepath); + PluginResult post_file(const std::string &room_id, const std::string &filepath, std::string &err_msg); PluginResult login(const std::string &username, const std::string &password, const std::string &homeserver, std::string &err_msg); PluginResult logout(); @@ -106,12 +107,16 @@ namespace QuickMedia { bool was_message_posted_by_me(const std::string &room_id, void *message) const; std::string message_get_author_displayname(RoomData *room_data, Message *message) const; + + // Cached + PluginResult get_config(int *upload_size); private: PluginResult sync_response_to_body_items(const Json::Value &root, RoomSyncMessages &room_messages); PluginResult get_previous_room_messages(const std::string &room_id, RoomData *room_data); void events_add_user_info(const Json::Value &events_json, RoomData *room_data); void events_add_messages(const Json::Value &events_json, RoomData *room_data, MessageDirection message_dir, RoomSyncMessages *room_messages, bool has_unread_notifications); void events_set_room_name(const Json::Value &events_json, RoomData *room_data); + PluginResult upload_file(const std::string &room_id, const std::string &filepath, UploadInfo &file_info, UploadInfo &thumbnail_info, std::string &err_msg); std::shared_ptr get_edited_message_original_message(RoomData *room_data, std::shared_ptr message); private: @@ -120,6 +125,7 @@ namespace QuickMedia { std::string username; std::string access_token; std::string homeserver; + std::optional upload_limit; std::string next_batch; }; } \ No newline at end of file diff --git a/src/AsyncImageLoader.cpp b/src/AsyncImageLoader.cpp index 020baf1..2fc33e8 100644 --- a/src/AsyncImageLoader.cpp +++ b/src/AsyncImageLoader.cpp @@ -63,10 +63,11 @@ namespace QuickMedia { // Returns empty string if no extension static const char* get_ext(const std::string &path) { + size_t slash_index = path.rfind('/'); size_t index = path.rfind('.'); - if(index == std::string::npos) - return ""; - return path.c_str() + index; + if(index != std::string::npos && (slash_index == std::string::npos || index > slash_index)) + return path.c_str() + index; + return ""; } bool AsyncImageLoader::load_thumbnail(const std::string &url, bool local, sf::Vector2i resize_target_size, bool use_tor, std::shared_ptr thumbnail_data) { diff --git a/src/Body.cpp b/src/Body.cpp index 6b352b3..4aa194b 100644 --- a/src/Body.cpp +++ b/src/Body.cpp @@ -473,6 +473,8 @@ namespace QuickMedia { if(item_thumbnail->loading_state == LoadingState::FINISHED_LOADING && item_thumbnail->image->getSize().x > 0 && item_thumbnail->image->getSize().y > 0) { if(!item_thumbnail->texture.loadFromImage(*item_thumbnail->image)) fprintf(stderr, "Warning: failed to load texture from image: %s\n", item->thumbnail_url.c_str()); + //item_thumbnail->texture.setSmooth(true); + //item_thumbnail->texture.generateMipmap(); item_thumbnail->image.reset(); item_thumbnail->loading_state = LoadingState::APPLIED_TO_TEXTURE; } @@ -631,6 +633,8 @@ namespace QuickMedia { if(!item->visible && !item->get_description().empty()) item->visible = string_find_case_insensitive(item->get_description(), text); } + + select_first_item(); } bool Body::no_items_visible() const { diff --git a/src/FileAnalyzer.cpp b/src/FileAnalyzer.cpp new file mode 100644 index 0000000..690e40e --- /dev/null +++ b/src/FileAnalyzer.cpp @@ -0,0 +1,227 @@ +#include "../include/FileAnalyzer.hpp" +#include "../include/Program.h" +#include +#include +#include +#include // TODO: Remove this dependency + +static const int MAGIC_NUMBER_BUFFER_SIZE = 36; + +namespace QuickMedia { + struct MagicNumber { + std::array data; + size_t size; + ContentType content_type; + }; + + // Sources: + // https://en.wikipedia.org/wiki/List_of_file_signatures + // https://mimesniff.spec.whatwg.org/ + + // What about audio ogg files that are not opus? + // TODO: Test all of these + static const std::array magic_numbers = { + MagicNumber{ {'R', 'I', 'F', 'F', -1, -1, -1, -1, 'A', 'V', 'I', ' '}, 12, ContentType::VIDEO_AVI }, + MagicNumber{ {0x00, 0x00, 0x00, -1, 'f', 't', 'y', 'p', 'i', 's', 'o', 'm'}, 12, ContentType::VIDEO_MP4 }, + MagicNumber{ {0x1A, 0x45, 0xDF, 0xA3}, 4, ContentType::VIDEO_WEBM }, + MagicNumber{ {'.', 's', 'n', 'd'}, 4, ContentType::AUDIO_BASIC }, + MagicNumber{ {'F', 'O', 'R', 'M', -1, -1, -1, -1, 'A', 'I', 'F', 'F'}, 12, ContentType::AUDIO_AIFF }, + MagicNumber{ { 'I', 'D', '3' }, 3, ContentType::AUDIO_MPEG }, + MagicNumber{ { 0xFF, 0xFB }, 2, ContentType::AUDIO_MPEG }, + MagicNumber{ { 0xFF, 0xF3 }, 2, ContentType::AUDIO_MPEG }, + MagicNumber{ { 0xFF, 0xF2 }, 2, ContentType::AUDIO_MPEG }, + //MagicNumber{ {'O', 'g', 'g', 'S', 0x00}, 5 }, + MagicNumber{ {'M', 'T', 'h', 'd', -1, -1, -1, -1}, 8, ContentType::AUDIO_MIDI }, + MagicNumber{ {'R', 'I', 'F', 'F', -1, -1, -1, -1, 'W', 'A', 'V', 'E'}, 12, ContentType::AUDIO_WAVE }, + MagicNumber{ {'f', 'L', 'a', 'C'}, 4, ContentType::AUDIO_FLAC }, + MagicNumber{ {'O', 'g', 'g', 'S', 0x00, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1 ,-1, -1, -1, -1, -1, -1, -1, 'O', 'p', 'u', 's', 'H', 'e', 'a', 'd'}, 36, ContentType::AUDIO_OPUS }, + MagicNumber{ {0xFF, 0xD8, 0xFF}, 3, ContentType::IMAGE_JPEG }, + MagicNumber{ {0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 8, ContentType::IMAGE_PNG }, + MagicNumber{ {'G', 'I', 'F', '8', '7', 'a'}, 6, ContentType::IMAGE_GIF }, + MagicNumber{ {'G', 'I', 'F', '8', '9', 'a'}, 6, ContentType::IMAGE_GIF }, + MagicNumber{ {'B', 'M'}, 2, ContentType::IMAGE_BMP }, + MagicNumber{ {'R', 'I', 'F', 'F', -1, -1, -1, -1, 'W', 'E', 'B', 'V', 'P'}, 6, ContentType::IMAGE_WEBP } + }; + + bool is_content_type_video(ContentType content_type) { + return content_type >= ContentType::VIDEO_AVI && content_type <= ContentType::VIDEO_WEBM; + } + + bool is_content_type_audio(ContentType content_type) { + return content_type >= ContentType::AUDIO_BASIC && content_type <= ContentType::AUDIO_OPUS; + } + + bool is_content_type_image(ContentType content_type) { + return content_type >= ContentType::IMAGE_JPEG && content_type <= ContentType::IMAGE_WEBP; + } + + const char* content_type_to_string(ContentType content_type) { + switch(content_type) { + case ContentType::UNKNOWN: return "application/octet-stream"; + case ContentType::VIDEO_AVI: return "video/avi"; + case ContentType::VIDEO_MP4: return "video/mp4"; + case ContentType::VIDEO_WEBM: return "video/webm"; + case ContentType::AUDIO_BASIC: return "audio/basic"; + case ContentType::AUDIO_AIFF: return "audio/aiff"; + case ContentType::AUDIO_MPEG: return "audio/mpeg"; + case ContentType::AUDIO_MIDI: return "audio/midi"; + case ContentType::AUDIO_WAVE: return "audio/wave"; + case ContentType::AUDIO_FLAC: return "audio/wave"; + case ContentType::AUDIO_OPUS: return "audio/ogg"; + case ContentType::IMAGE_JPEG: return "image/jpeg"; + case ContentType::IMAGE_PNG: return "image/png"; + case ContentType::IMAGE_GIF: return "image/gif"; + case ContentType::IMAGE_BMP: return "image/bmp"; + case ContentType::IMAGE_WEBP: return "image/webp"; + } + return "application/octet-stream"; + } + + static int accumulate_string(char *data, int size, void *userdata) { + std::string *str = (std::string*)userdata; + str->append(data, size); + return 0; + } + + bool video_get_first_frame(const char *filepath, const char *destination_path) { + const char *program_args[] = { "ffmpeg", "-y", "-v", "quiet", "-i", filepath, "-vframes", "1", "-f", "singlejpeg", destination_path, nullptr }; + std::string ffmpeg_result; + if(exec_program(program_args, nullptr, nullptr) != 0) { + fprintf(stderr, "Failed to execute ffmpeg, maybe its not installed?\n"); + return false; + } + return true; + } + + // TODO: Remove dependency on ffprobe + static bool ffprobe_extract_metadata(const char *filepath, std::optional &dimensions, std::optional &duration_seconds) { + const char *program_args[] = { "ffprobe", "-v", "quiet", "-print_format", "json", "-show_streams", "--", filepath, nullptr }; + std::string ffprobe_result; + if(exec_program(program_args, accumulate_string, &ffprobe_result) != 0) { + fprintf(stderr, "Failed to execute ffprobe, maybe its not installed?\n"); + return false; + } + + Json::Value json_root; + Json::CharReaderBuilder json_builder; + std::unique_ptr json_reader(json_builder.newCharReader()); + std::string json_errors; + if(!json_reader->parse(&ffprobe_result[0], &ffprobe_result[ffprobe_result.size()], &json_root, &json_errors)) { + fprintf(stderr, "ffprobe response parsing failed: %s\n", json_errors.c_str()); + return false; + } + + if(!json_root.isObject()) + return false; + + const Json::Value &streams_json = json_root["streams"]; + if(!streams_json.isArray()) + return false; + + for(const Json::Value &stream_json : streams_json) { + if(!stream_json.isObject()) + continue; + + const Json::Value &codec_type = stream_json["codec_type"]; + if(!codec_type.isString()) + continue; + + if(strcmp(codec_type.asCString(), "video") == 0) { + const Json::Value &width_json = stream_json["width"]; + const Json::Value &height_json = stream_json["height"]; + const Json::Value &duration_json = stream_json["duration"]; + if(width_json.isNumeric() && height_json.isNumeric()) + dimensions = { width_json.asInt(), height_json.asInt() }; + if(duration_json.isString()) + duration_seconds = atof(duration_json.asCString()); + break; + } else if(strcmp(codec_type.asCString(), "audio") == 0) { + const Json::Value &duration_json = stream_json["duration"]; + if(duration_json.isString()) + duration_seconds = atof(duration_json.asCString()); + // No break here because if there is video after this, we want it to overwrite this + } + } + + return true; + } + + FileAnalyzer::FileAnalyzer() : content_type(ContentType::UNKNOWN), file_size(0), loaded(false) { + + } + + bool FileAnalyzer::load_file(const char *filepath) { + if(loaded) { + fprintf(stderr, "File already loaded\n"); + return false; + } + + FILE *file = fopen(filepath, "rb"); + if(!file) { + perror(filepath); + return false; + } + + content_type = ContentType::UNKNOWN; + file_size = 0; + dimensions = std::nullopt; + duration_seconds = std::nullopt; + + struct stat stat; + if(fstat(fileno(file), &stat) == -1) { + perror(filepath); + fclose(file); + return false; + } + + file_size = stat.st_size; + + unsigned char magic_number_buffer[MAGIC_NUMBER_BUFFER_SIZE]; + size_t num_bytes_read = fread(magic_number_buffer, 1, sizeof(magic_number_buffer), file); + fclose(file); + + for(const MagicNumber &magic_number : magic_numbers) { + if(num_bytes_read >= magic_number.size) { + bool matching_magic_numbers = true; + for(size_t i = 0; i < magic_number.size; ++i) { + if(magic_number.data[i] != (int)magic_number_buffer[i] && (int)magic_number.data[i] != -1) { + matching_magic_numbers = false; + break; + } + } + if(matching_magic_numbers) { + content_type = magic_number.content_type; + break; + } + } + } + + if(content_type != ContentType::UNKNOWN) { + if(!ffprobe_extract_metadata(filepath, dimensions, duration_seconds)) { + // This is not an error, matrix allows files to be uploaded without metadata + fprintf(stderr, "Failed to extract metadata from file: %s, is ffprobe not installed?\n", filepath); + } + if(is_content_type_image(content_type)) + duration_seconds = std::nullopt; + } + + loaded = true; + return true; + } + + ContentType FileAnalyzer::get_content_type() const { + return content_type; + } + + size_t FileAnalyzer::get_file_size() const { + return file_size; + } + + std::optional FileAnalyzer::get_dimensions() const { + return dimensions; + } + + std::optional FileAnalyzer::get_duration_seconds() const { + return duration_seconds; + } +} \ No newline at end of file diff --git a/src/ImageViewer.cpp b/src/ImageViewer.cpp index 24894fd..77c53b4 100644 --- a/src/ImageViewer.cpp +++ b/src/ImageViewer.cpp @@ -261,6 +261,8 @@ namespace QuickMedia { if(page_data) { if(page_data->image_status == ImageStatus::LOADED && page_data->image->getSize().x > 0 && page_data->image->getSize().y > 0) { if(page_data->texture.loadFromImage(*page_data->image)) { + //page_data->texture.setSmooth(true); + //page_data->texture.generateMipmap(); double height_before = get_page_size(page_i).y; page_data->image_status = ImageStatus::APPLIED_TO_TEXTURE; page_data->sprite.setTexture(page_data->texture, true); diff --git a/src/QuickMedia.cpp b/src/QuickMedia.cpp index 8835b1d..fd49556 100644 --- a/src/QuickMedia.cpp +++ b/src/QuickMedia.cpp @@ -3117,6 +3117,7 @@ namespace QuickMedia { if(attached_image_texture->loadFromMemory(image_data.data(), image_data.size())) { attached_image_texture->setSmooth(true); + //attached_image_texture->generateMipmap(); attached_image_sprite.setTexture(*attached_image_texture, true); } else { BodyItem *selected_item = body->get_selected(); @@ -3280,14 +3281,17 @@ namespace QuickMedia { source.a + diff_a * progress); } - static std::string extract_first_line(const std::string &str) { + static std::string extract_first_line(const std::string &str, size_t max_length) { size_t index = str.find('\n'); - if(index == std::string::npos) + if(index == std::string::npos) { + if(str.size() > max_length) + return str.substr(0, max_length) + "..."; return str; - else if(index == 0) + } else if(index == 0) { return ""; - else - return str.substr(0, index); + } else { + return str.substr(0, std::min(index, max_length)) + "..."; + } } void Program::chat_page() { @@ -3361,7 +3365,7 @@ namespace QuickMedia { if(only_show_mentions) { std::string room_desc; if(!messages.empty()) - room_desc = matrix->message_get_author_displayname(room, messages.back().get()) + ": " + extract_first_line(messages.back()->body); + room_desc = matrix->message_get_author_displayname(room, messages.back().get()) + ": " + extract_first_line(messages.back()->body, 150); if(was_mentioned) { room_desc += "\n** You were mentioned **"; // TODO: Better notification? room_body_item_it->second.body_item->title_color = sf::Color(255, 100, 100); @@ -3369,7 +3373,7 @@ namespace QuickMedia { } room_body_item_it->second.body_item->set_description(std::move(room_desc)); } else if(!messages.empty()) { - std::string room_desc = "Unread: " + matrix->message_get_author_displayname(room, messages.back().get()) + ": " + extract_first_line(messages.back()->body); + std::string room_desc = "Unread: " + matrix->message_get_author_displayname(room, messages.back().get()) + ": " + extract_first_line(messages.back()->body, 150); if(was_mentioned) room_desc += "\n** You were mentioned **"; // TODO: Better notification? room_body_item_it->second.body_item->set_description(std::move(room_desc)); @@ -3409,7 +3413,7 @@ namespace QuickMedia { if(text.isEmpty()) return false; - if(text[0] == '/') { + if(chat_state == ChatState::TYPING_MESSAGE && text[0] == '/') { std::string command = text; strip(command); if(command == "/upload") { @@ -3432,7 +3436,7 @@ namespace QuickMedia { if(chat_state == ChatState::TYPING_MESSAGE) { // TODO: Make asynchronous - if(matrix->post_message(current_room_id, text) == PluginResult::OK) { + if(matrix->post_message(current_room_id, text, std::nullopt, std::nullopt) == PluginResult::OK) { chat_input.set_editable(false); chat_state = ChatState::NAVIGATING; return true; @@ -3575,11 +3579,18 @@ namespace QuickMedia { } }; + bool is_window_focused = true; + while (current_page == Page::CHAT) { sf::Int32 frame_time_ms = frame_timer.restart().asMilliseconds(); while (window.pollEvent(event)) { base_event_handler(event, Page::EXIT, false, false, false); - if(event.type == sf::Event::Resized || event.type == sf::Event::GainedFocus) { + if(event.type == sf::Event::GainedFocus) { + is_window_focused = true; + redraw = true; + } else if(event.type == sf::Event::LostFocus) { + is_window_focused = false; + } else if(event.type == sf::Event::Resized || event.type == sf::Event::GainedFocus) { redraw = true; } else if(event.type == sf::Event::KeyPressed && chat_state == ChatState::NAVIGATING) { if(event.key.code == sf::Keyboard::Up || event.key.code == sf::Keyboard::PageUp || event.key.code == sf::Keyboard::Home) { @@ -3833,8 +3844,11 @@ namespace QuickMedia { } else { // TODO: Make asynchronous. // TODO: Upload multiple files. - if(matrix->post_file(current_room_id, selected_files[0]) != PluginResult::OK) - show_notification("QuickMedia", "Failed to upload image to room", Urgency::CRITICAL); + std::string err_msg; + if(matrix->post_file(current_room_id, selected_files[0], err_msg) != PluginResult::OK) { + std::string desc = "Failed to upload image to room, error: " + err_msg; + show_notification("QuickMedia", desc.c_str(), Urgency::CRITICAL); + } } redraw = true; break; @@ -3887,6 +3901,8 @@ namespace QuickMedia { if(room_avatar_thumbnail_data->loading_state == LoadingState::FINISHED_LOADING && room_avatar_thumbnail_data->image->getSize().x > 0 && room_avatar_thumbnail_data->image->getSize().y > 0) { if(!room_avatar_thumbnail_data->texture.loadFromImage(*room_avatar_thumbnail_data->image)) fprintf(stderr, "Warning: failed to load texture for room avatar\n"); + room_avatar_thumbnail_data->texture.setSmooth(true); + //room_avatar_thumbnail_data->texture.generateMipmap(); room_avatar_thumbnail_data->image.reset(); room_avatar_thumbnail_data->loading_state = LoadingState::APPLIED_TO_TEXTURE; room_avatar_sprite.setTexture(room_avatar_thumbnail_data->texture, true); @@ -4152,7 +4168,7 @@ namespace QuickMedia { if(tabs[selected_tab].type == ChatTabType::MESSAGES) { BodyItem *last_visible_item = tabs[selected_tab].body->get_last_fully_visible_item(); - if(chat_state != ChatState::URL_SELECTION && current_room_body_data && last_visible_item && !setting_read_marker && read_marker_timer.getElapsedTime().asMilliseconds() >= read_marker_timeout_ms) { + if(is_window_focused && chat_state != ChatState::URL_SELECTION && current_room_body_data && last_visible_item && !setting_read_marker && read_marker_timer.getElapsedTime().asMilliseconds() >= read_marker_timeout_ms) { Message *message = (Message*)last_visible_item->userdata; if(message->timestamp > current_room_body_data->last_read_message_timestamp) { current_room_body_data->last_read_message_timestamp = message->timestamp; diff --git a/src/main.cpp b/src/main.cpp index 2899565..3383363 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -5,6 +5,7 @@ int main(int argc, char **argv) { chdir(dirname(argv[0])); + setlocale(LC_ALL, "C"); // Sigh... stupid C XInitThreads(); QuickMedia::Program program; return program.run(argc, argv); diff --git a/src/plugins/Matrix.cpp b/src/plugins/Matrix.cpp index e182c11..60fc8a9 100644 --- a/src/plugins/Matrix.cpp +++ b/src/plugins/Matrix.cpp @@ -1,6 +1,5 @@ #include "../../plugins/Matrix.hpp" #include "../../include/Storage.hpp" -#include "../../include/ImageUtils.hpp" #include #include #include @@ -742,16 +741,18 @@ namespace QuickMedia { return result.str(); } - static const char* message_type_to_request_msg_type_str(MessageType msgtype) { - switch(msgtype) { - case MessageType::TEXT: return "m.text"; - case MessageType::IMAGE: return "m.image"; - case MessageType::VIDEO: return "m.video"; - } - return "m.text"; + static const char* content_type_to_message_type(ContentType content_type) { + if(is_content_type_video(content_type)) + return "m.video"; + else if(is_content_type_audio(content_type)) + return "m.audio"; + else if(is_content_type_image(content_type)) + return "m.image"; + else + return "m.file"; } - PluginResult Matrix::post_message(const std::string &room_id, const std::string &body, const std::string &url, MessageType msgtype, MessageInfo *info) { + PluginResult Matrix::post_message(const std::string &room_id, const std::string &body, const std::optional &file_info, const std::optional &thumbnail_info) { char random_characters[18]; if(!generate_random_characters(random_characters, sizeof(random_characters))) return PluginResult::ERR; @@ -760,7 +761,7 @@ namespace QuickMedia { std::string formatted_body; bool contains_formatted_text = false; - if(msgtype == MessageType::TEXT) { + if(!file_info) { int line = 0; string_split(body, '\n', [&formatted_body, &contains_formatted_text, &line](const char *str, size_t size){ if(line > 0) @@ -781,22 +782,42 @@ namespace QuickMedia { } Json::Value request_data(Json::objectValue); - request_data["msgtype"] = message_type_to_request_msg_type_str(msgtype); + request_data["msgtype"] = (file_info ? content_type_to_message_type(file_info->content_type) : "m.text"); request_data["body"] = body; if(contains_formatted_text) { request_data["format"] = "org.matrix.custom.html"; request_data["formatted_body"] = std::move(formatted_body); } - if(msgtype == MessageType::IMAGE) { - if(info) { - Json::Value info_json(Json::objectValue); - info_json["size"] = info->size; - info_json["w"] = info->w; - info_json["h"] = info->h; - info_json["mimetype"] = info->mimetype; - request_data["info"] = std::move(info_json); + + // TODO: Add hashblur? + if(file_info) { + Json::Value info_json(Json::objectValue); + info_json["size"] = file_info->file_size; + info_json["mimetype"] = content_type_to_string(file_info->content_type); + if(file_info->dimensions) { + info_json["w"] = file_info->dimensions->width; + info_json["h"] = file_info->dimensions->height; } - request_data["url"] = url; + if(file_info->duration_seconds) { + // TODO: Check for overflow? + info_json["duration"] = (int)file_info->duration_seconds.value() * 1000; + } + + if(thumbnail_info) { + Json::Value thumbnail_info_json(Json::objectValue); + thumbnail_info_json["size"] = thumbnail_info->file_size; + thumbnail_info_json["mimetype"] = content_type_to_string(thumbnail_info->content_type); + if(thumbnail_info->dimensions) { + thumbnail_info_json["w"] = thumbnail_info->dimensions->width; + thumbnail_info_json["h"] = thumbnail_info->dimensions->height; + } + + info_json["thumbnail_url"] = thumbnail_info->content_uri; + info_json["info"] = std::move(thumbnail_info_json); + } + + request_data["info"] = std::move(info_json); + request_data["url"] = file_info->content_uri; } Json::StreamWriterBuilder builder; @@ -902,7 +923,7 @@ namespace QuickMedia { relates_to_json["m.in_reply_to"] = std::move(in_reply_to_json); Json::Value request_data(Json::objectValue); - request_data["msgtype"] = message_type_to_request_msg_type_str(MessageType::TEXT); + request_data["msgtype"] = "m.text"; // TODO: Allow image reply? element doesn't do that but we could! request_data["body"] = create_body_for_message_reply(room_it->second.get(), relates_to_message_raw, body); // Yes, the reply is to the edited message but the event_id reference is to the original message... request_data["m.relates_to"] = std::move(relates_to_json); @@ -1002,7 +1023,7 @@ namespace QuickMedia { relates_to_json["rel_type"] = "m.replace"; Json::Value request_data(Json::objectValue); - request_data["msgtype"] = message_type_to_request_msg_type_str(MessageType::TEXT); + request_data["msgtype"] = "m.text"; // TODO: Allow other types of edits request_data["body"] = " * " + body; if(contains_formatted_text) { request_data["format"] = "org.matrix.custom.html"; @@ -1157,82 +1178,129 @@ namespace QuickMedia { return filename; } - static const char* image_type_to_mimetype(ImageType image_type) { - switch(image_type) { - case ImageType::PNG: return "image/png"; - case ImageType::GIF: return "image/gif"; - case ImageType::JPG: return "image/jpeg"; - } - return "application/octet-stream"; + PluginResult Matrix::post_file(const std::string &room_id, const std::string &filepath, std::string &err_msg) { + UploadInfo file_info; + UploadInfo thumbnail_info; + PluginResult upload_file_result = upload_file(room_id, filepath, file_info, thumbnail_info, err_msg); + if(upload_file_result != PluginResult::OK) + return upload_file_result; + + std::optional file_info_opt = std::move(file_info); + std::optional thumbnail_info_opt; + if(!thumbnail_info.content_uri.empty()) + thumbnail_info_opt = std::move(thumbnail_info); + + const char *filename = file_get_filename(filepath); + return post_message(room_id, filename, file_info_opt, thumbnail_info_opt); } - PluginResult Matrix::post_file(const std::string &room_id, const std::string &filepath) { - int image_width, image_height; - ImageType image_type; - if(!image_get_resolution(filepath, &image_width, &image_height, &image_type)) { - fprintf(stderr, "Failed to get resolution of image: %s. Only image uploads are currently supported\n", filepath.c_str()); + PluginResult Matrix::upload_file(const std::string &room_id, const std::string &filepath, UploadInfo &file_info, UploadInfo &thumbnail_info, std::string &err_msg) { + FileAnalyzer file_analyzer; + if(!file_analyzer.load_file(filepath.c_str())) { + err_msg = "Failed to load " + filepath; return PluginResult::ERR; } - const char *mimetype = image_type_to_mimetype(image_type); + file_info.content_type = file_analyzer.get_content_type(); + file_info.file_size = file_analyzer.get_file_size(); + file_info.dimensions = file_analyzer.get_dimensions(); + file_info.duration_seconds = file_analyzer.get_duration_seconds(); - // TODO: What if the file changes after this? is the file size really needed? - size_t file_size; - if(file_get_size(filepath, &file_size) != 0) { - fprintf(stderr, "Failed to get size of image: %s\n", filepath.c_str()); + int upload_limit; + PluginResult config_result = get_config(&upload_limit); + if(config_result != PluginResult::OK) { + err_msg = "Failed to get file size limit from server"; + return config_result; + } + + // Checking for sane file size limit client side, to prevent loading a huge file and crashing + if(file_analyzer.get_file_size() > 300 * 1024 * 1024) { // 300mb + err_msg = "File is too large! client-side limit is set to 300mb"; return PluginResult::ERR; } - // TODO: Check server file limit first: GET https://glowers.club/_matrix/media/r0/config - // and also have a sane limit client-side. - if(file_size > 100 * 1024 * 1024) { - fprintf(stderr, "Upload file size it too large!, max size is currently 100mb\n"); + if((int)file_analyzer.get_file_size() > upload_limit) { + err_msg = "File is too large! max upload size on your homeserver is " + std::to_string(upload_limit) + " bytes, the file you tried to upload is " + std::to_string(file_analyzer.get_file_size()) + " bytes"; return PluginResult::ERR; } + if(is_content_type_video(file_analyzer.get_content_type())) { + // TODO: Also upload thumbnail for images. Take into consideration below upload_file, we dont want to upload thumbnail of thumbnail + char tmp_filename[] = "/tmp/quickmedia_video_frame_XXXXXX"; + int tmp_file = mkstemp(tmp_filename); + if(tmp_file != -1) { + if(video_get_first_frame(filepath.c_str(), tmp_filename)) { + UploadInfo upload_info_ignored; // Ignore because it wont be set anyways. Thumbnails dont have thumbnails. + PluginResult upload_thumbnail_result = upload_file(room_id, tmp_filename, thumbnail_info, upload_info_ignored, err_msg); + if(upload_thumbnail_result != PluginResult::OK) { + close(tmp_file); + remove(tmp_filename); + return upload_thumbnail_result; + } + } else { + fprintf(stderr, "Failed to get first frame of video, ignoring thumbnail...\n"); + } + close(tmp_file); + remove(tmp_filename); + } else { + fprintf(stderr, "Failed to create temporary file for video thumbnail, ignoring thumbnail...\n"); + } + } + std::vector additional_args = { { "-X", "POST" }, - { "-H", std::string("content-type: ") + mimetype }, + { "-H", std::string("content-type: ") + content_type_to_string(file_analyzer.get_content_type()) }, { "-H", "Authorization: Bearer " + access_token }, { "--data-binary", "@" + filepath } }; const char *filename = file_get_filename(filepath); + std::string filename_escaped = url_param_encode(filename); char url[512]; - snprintf(url, sizeof(url), "%s/_matrix/media/r0/upload?filename=%s", homeserver.c_str(), filename); + snprintf(url, sizeof(url), "%s/_matrix/media/r0/upload?filename=%s", homeserver.c_str(), filename_escaped.c_str()); fprintf(stderr, "Upload url: |%s|\n", url); std::string server_response; - if(download_to_string(url, server_response, std::move(additional_args), use_tor, true) != DownloadResult::OK) + if(download_to_string(url, server_response, std::move(additional_args), use_tor, true, false) != DownloadResult::OK) { + err_msg = "Upload request failed, reason: " + server_response; return PluginResult::NET_ERR; + } - if(server_response.empty()) + if(server_response.empty()) { + err_msg = "Got corrupt response from server"; return PluginResult::ERR; + } Json::Value json_root; Json::CharReaderBuilder json_builder; std::unique_ptr json_reader(json_builder.newCharReader()); std::string json_errors; if(!json_reader->parse(&server_response[0], &server_response[server_response.size()], &json_root, &json_errors)) { - fprintf(stderr, "Matrix upload response parse error: %s\n", json_errors.c_str()); + err_msg = "Matrix upload response parse error: " + json_errors; return PluginResult::ERR; } - if(!json_root.isObject()) + if(!json_root.isObject()) { + err_msg = "Got corrupt response from server"; + return PluginResult::ERR; + } + + const Json::Value &error_json = json_root["error"]; + if(error_json.isString()) { + err_msg = error_json.asString(); return PluginResult::ERR; + } const Json::Value &content_uri_json = json_root["content_uri"]; - if(!content_uri_json.isString()) + if(!content_uri_json.isString()) { + err_msg = "Missing content_uri is server response"; return PluginResult::ERR; + } fprintf(stderr, "Matrix upload, response content uri: %s\n", content_uri_json.asCString()); - MessageInfo message_info; - message_info.size = file_size; - message_info.w = image_width; - message_info.h = image_height; - message_info.mimetype = mimetype; - return post_message(room_id, filename, content_uri_json.asString(), MessageType::IMAGE, &message_info); + file_info.content_uri = content_uri_json.asString(); + return PluginResult::OK; } PluginResult Matrix::login(const std::string &username, const std::string &password, const std::string &homeserver, std::string &err_msg) { @@ -1279,9 +1347,9 @@ namespace QuickMedia { return PluginResult::ERR; } - const Json::Value &error = json_root["error"]; - if(error.isString()) { - err_msg = error.asString(); + const Json::Value &error_json = json_root["error"]; + if(error_json.isString()) { + err_msg = error_json.asString(); return PluginResult::ERR; } @@ -1340,6 +1408,7 @@ namespace QuickMedia { username.clear(); access_token.clear(); homeserver.clear(); + upload_limit.reset(); next_batch.clear(); return PluginResult::OK; @@ -1394,9 +1463,9 @@ namespace QuickMedia { return PluginResult::ERR; } - const Json::Value &error = json_root["error"]; - if(error.isString()) { - err_msg = error.asString(); + const Json::Value &error_json = json_root["error"]; + if(error_json.isString()) { + err_msg = error_json.asString(); return PluginResult::ERR; } @@ -1554,4 +1623,51 @@ namespace QuickMedia { } return room_data->user_info[message->user_id].display_name; } + + PluginResult Matrix::get_config(int *upload_size) { + // TODO: What if the upload limit changes? is it possible for the upload limit to change while the server is running? + if(upload_limit) { + *upload_size = upload_limit.value(); + return PluginResult::OK; + } + + *upload_size = 0; + + std::vector additional_args = { + { "-H", "Authorization: Bearer " + access_token } + }; + + char url[512]; + snprintf(url, sizeof(url), "%s/_matrix/media/r0/config", homeserver.c_str()); + fprintf(stderr, "load initial room data, url: |%s|\n", url); + + std::string server_response; + if(download_to_string(url, server_response, std::move(additional_args), use_tor, true) != DownloadResult::OK) { + fprintf(stderr, "Matrix /config failed\n"); + return PluginResult::NET_ERR; + } + + if(server_response.empty()) + return PluginResult::ERR; + + Json::Value json_root; + Json::CharReaderBuilder json_builder; + std::unique_ptr json_reader(json_builder.newCharReader()); + std::string json_errors; + if(!json_reader->parse(&server_response[0], &server_response[server_response.size()], &json_root, &json_errors)) { + fprintf(stderr, "Matrix parsing /config response failed, error: %s\n", json_errors.c_str()); + return PluginResult::ERR; + } + + if(!json_root.isObject()) + return PluginResult::ERR; + + const Json::Value &upload_size_json = json_root["m.upload.size"]; + if(!upload_size_json.isNumeric()) + return PluginResult::ERR; + + upload_limit = upload_size_json.asInt(); + *upload_size = upload_limit.value(); + return PluginResult::OK; + } } \ No newline at end of file -- cgit v1.2.3