Initial commit
This commit is contained in:
commit
71f9ebaed7
|
@ -0,0 +1,4 @@
|
|||
*.conf
|
||||
*.sqlite
|
||||
*.pro.user
|
||||
build-*/
|
|
@ -0,0 +1,634 @@
|
|||
#define _X_OPEN_SOURCE
|
||||
|
||||
#include "tui.h"
|
||||
#include "yt.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <ctime>
|
||||
#include <unordered_map>
|
||||
#include <fstream>
|
||||
|
||||
#include <time.h>
|
||||
#include <stdio.h>
|
||||
|
||||
#include <curl/curl.h>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <sqlite3.h>
|
||||
|
||||
static std::string user_home;
|
||||
|
||||
std::vector<Channel> channels;
|
||||
std::unordered_map<std::string, std::vector<Video>> videos;
|
||||
|
||||
std::optional<size_t> selected_channel;
|
||||
size_t current_video_count = 0;
|
||||
size_t selected_video = 0;
|
||||
size_t videos_per_page = 0;
|
||||
size_t current_page_count = 0;
|
||||
size_t title_offset = 0;
|
||||
bool any_title_in_next_half = false;
|
||||
|
||||
#define SC(x) { const int res = (x); if(res != SQLITE_OK && res != SQLITE_ROW && res != SQLITE_DONE) { fprintf(stderr, "%s failed: (%d) %s\n", #x, res, sqlite3_errstr(res)); std::abort(); }}
|
||||
|
||||
static termpaint_attr* get_attr(const AttributeSetType type, const bool highlight=false)
|
||||
{
|
||||
return highlight ? attributes[type].highlight : attributes[type].normal;
|
||||
}
|
||||
|
||||
void draw_channel_list(const std::vector<Video> &videos)
|
||||
{
|
||||
const size_t cols = termpaint_surface_width(surface);
|
||||
const size_t rows = termpaint_surface_height(surface);
|
||||
|
||||
const size_t column_spacing = 2;
|
||||
|
||||
const size_t status_column = 0;
|
||||
const size_t status_width = 1;
|
||||
|
||||
const size_t date_column = status_column + status_width + column_spacing;
|
||||
const size_t date_width = std::string("xxxx-xx-xx xx:xx").size();
|
||||
const size_t first_name_column = date_column + date_width + column_spacing;
|
||||
const size_t last_name_column = cols;
|
||||
const size_t name_quater = (last_name_column - first_name_column) / 4;
|
||||
|
||||
const size_t start_row = 2;
|
||||
const size_t available_rows = rows - 2;
|
||||
videos_per_page = available_rows;
|
||||
|
||||
const int pages = videos.size() / available_rows;
|
||||
const int cur_page = selected_video / available_rows;
|
||||
|
||||
const std::string channel_name = std::string("Channel: ") + channels[*selected_channel].name.c_str();
|
||||
termpaint_surface_write_with_attr(surface, 0, 0, channel_name.c_str(), get_attr(ASNormal));
|
||||
|
||||
if(pages > 1) {
|
||||
std::string text = "(Page " + std::to_string(cur_page + 1) + "/" + std::to_string(pages + 1) + ")";
|
||||
const size_t w = string_width(text);
|
||||
termpaint_surface_write_with_attr(surface, cols - w, 0, text.c_str(), attributes[ASNormal].normal);
|
||||
}
|
||||
|
||||
termpaint_surface_write_with_attr(surface, date_column, 1, "Date", get_attr(ASNormal));
|
||||
termpaint_surface_write_with_attr(surface, first_name_column, 1, "Title", get_attr(ASNormal));
|
||||
|
||||
any_title_in_next_half = false;
|
||||
|
||||
size_t cur_entry = 0;
|
||||
for(size_t i = cur_page*available_rows; i < videos.size(); i++) {
|
||||
const size_t row = start_row + cur_entry;
|
||||
const bool selected = i == selected_video;
|
||||
const Video &video = videos.at(i);
|
||||
termpaint_attr *attr = get_attr(video.flags & kWatched ? ASWatched : ASUnwatched, selected);
|
||||
|
||||
termpaint_surface_clear_rect_with_attr(surface, 0, row, cols, 1, attr);
|
||||
|
||||
std::vector<char> dt(date_width + 10, 0);
|
||||
|
||||
struct tm tm;
|
||||
memset(&tm, 0, sizeof(tm));
|
||||
if(strptime(video.published.c_str(), "%FT%T%z", &tm) != nullptr) {
|
||||
strftime(dt.data(), date_width + 10, "%F %H:%M", &tm);
|
||||
}
|
||||
|
||||
termpaint_surface_write_with_attr(surface, date_column, row, dt.data(), attr);
|
||||
|
||||
bool in_this_quater = title_offset * name_quater < video.tui_title_width;
|
||||
any_title_in_next_half = any_title_in_next_half || ((title_offset + 2) * name_quater) < video.tui_title_width;
|
||||
if(in_this_quater)
|
||||
termpaint_surface_write_with_attr_clipped(surface, first_name_column, row, video.title.c_str() + (name_quater * title_offset), attr, first_name_column, last_name_column);
|
||||
else
|
||||
termpaint_surface_write_with_attr(surface, first_name_column, row, "←", attr);
|
||||
std::string status_symbol = "";
|
||||
if(video.flags & kWatched) {
|
||||
status_symbol = "*";
|
||||
}
|
||||
if(video.flags & kDownloaded) {
|
||||
status_symbol = "↓";
|
||||
}
|
||||
termpaint_surface_write_with_attr(surface, status_column, row, status_symbol.c_str(), attr);
|
||||
|
||||
if(++cur_entry > available_rows)
|
||||
break;
|
||||
}
|
||||
|
||||
if(!any_title_in_next_half && title_offset > 0)
|
||||
title_offset--;
|
||||
}
|
||||
|
||||
void draw_no_channels_msg()
|
||||
{
|
||||
const size_t cols = termpaint_surface_width(surface);
|
||||
const size_t rows = termpaint_surface_height(surface);
|
||||
|
||||
const std::string text("No configured channels.\n"
|
||||
"Press the following keys to add one:\n"
|
||||
"a Add by name\n"
|
||||
"A Add by ID\n"
|
||||
"Or press h for help or ^q to quit.");
|
||||
{
|
||||
std::pair<size_t, size_t> size = string_size(text);
|
||||
write_multiline_string((cols - size.first) / 2, (rows - size.second) / 2, text, get_attr(ASNormal));
|
||||
}
|
||||
}
|
||||
|
||||
static void pad_to_width(std::string &str, const size_t width)
|
||||
{
|
||||
const size_t current = string_width(str);
|
||||
str.append(width-current, ' ');
|
||||
}
|
||||
|
||||
struct action
|
||||
{
|
||||
int type;
|
||||
std::string string;
|
||||
int modifier;
|
||||
|
||||
std::function<void(void)> func;
|
||||
const std::string help;
|
||||
};
|
||||
|
||||
struct helpitem {
|
||||
std::string key;
|
||||
std::string text;
|
||||
};
|
||||
|
||||
void draw_help(const std::vector<helpitem> &items)
|
||||
{
|
||||
const size_t rows = termpaint_surface_height(surface);
|
||||
|
||||
const size_t rows_per_column = rows / 3 * 2;
|
||||
|
||||
std::vector<std::vector<std::string>> column_texts;
|
||||
|
||||
const size_t text_columns = 1 + items.size() / rows_per_column;
|
||||
|
||||
size_t item = 0;
|
||||
for(size_t column=0; column<text_columns; column++) {
|
||||
const size_t items_this_column = std::min(items.size() - item, rows_per_column);
|
||||
std::vector<std::string> texts;
|
||||
size_t key_width = 0;
|
||||
size_t text_width = 0;
|
||||
for(size_t i=0; i<items_this_column; i++) {
|
||||
key_width = std::max(string_width(items[item + i].key), key_width);
|
||||
text_width = std::max(string_width(items[item + i].text), text_width);
|
||||
}
|
||||
for(size_t i=0; i<items_this_column; i++) {
|
||||
std::string s = items[item].key;
|
||||
pad_to_width(s, key_width);
|
||||
s.append(" ");
|
||||
s.append(items[item].text);
|
||||
pad_to_width(s, key_width + 1 + text_width);
|
||||
texts.push_back(s);
|
||||
|
||||
item++;
|
||||
}
|
||||
column_texts.push_back(texts);
|
||||
}
|
||||
|
||||
std::string to_show;
|
||||
for(size_t i=0; i<rows_per_column; i++) {
|
||||
for(size_t column=0; column<text_columns; column++) {
|
||||
if(i >= column_texts[column].size())
|
||||
continue;
|
||||
to_show.append(column_texts[column][i]);
|
||||
if(column+1 < text_columns)
|
||||
to_show.append(" ");
|
||||
}
|
||||
to_show.append("\n");
|
||||
}
|
||||
|
||||
message_box("Help", to_show.c_str());
|
||||
}
|
||||
|
||||
static std::unordered_map<std::string, std::string> key_symbols = {
|
||||
{"ArrowLeft", "←"},
|
||||
{"ArrowUp", "↑"},
|
||||
{"ArrowRight", "→"},
|
||||
{"ArrowDown", "↓"},
|
||||
{"Space", "[Space]"},
|
||||
};
|
||||
|
||||
std::string format_key(const action &action) {
|
||||
std::string str;
|
||||
if(action.modifier & TERMPAINT_MOD_CTRL)
|
||||
str.append("C-");
|
||||
if(action.modifier & TERMPAINT_MOD_ALT)
|
||||
str.append("M-");
|
||||
if(action.type == TERMPAINT_EV_KEY) {
|
||||
auto it = key_symbols.find(action.string);
|
||||
if(it == key_symbols.end())
|
||||
str.append(action.string);
|
||||
else
|
||||
str.append(it->second);
|
||||
} else {
|
||||
str.append(action.string);
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
bool handle_action(const Event &event, const std::vector<action> &actions)
|
||||
{
|
||||
if(event.type == EV_TIMEOUT)
|
||||
return false;
|
||||
|
||||
const auto it = std::find_if(actions.cbegin(), actions.cend(), [&](const action &a) {
|
||||
return a.type == event.type
|
||||
&& a.string == event.string
|
||||
&& event.modifier == a.modifier;
|
||||
});
|
||||
if(it == actions.cend()) {
|
||||
if(event.type == TERMPAINT_EV_KEY && event.string == "F1") {
|
||||
std::vector<helpitem> items = {{"F1", "Display this help"}};
|
||||
for(const action &action: actions) {
|
||||
if(action.help.empty())
|
||||
continue;
|
||||
items.push_back({format_key(action), action.help});
|
||||
}
|
||||
draw_help(items);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if(it->func)
|
||||
it->func();
|
||||
return true;
|
||||
}
|
||||
|
||||
sqlite3 *db;
|
||||
|
||||
void load_videos_for_channel(const std::string &channelId, bool force=false)
|
||||
{
|
||||
if(videos.find(channelId) != videos.end() && !force)
|
||||
return;
|
||||
std::vector<Video> &channelVideos = videos[channelId];
|
||||
channelVideos.clear();
|
||||
if(channels[*selected_channel].id == channelId)
|
||||
selected_video = 0;
|
||||
|
||||
sqlite3_stmt *query;
|
||||
SC(sqlite3_prepare_v2(db, "SELECT * FROM videos WHERE channelId=?1 ORDER BY published DESC;", -1, &query, nullptr));
|
||||
|
||||
SC(sqlite3_bind_text(query, 1, channelId.c_str(), -1, SQLITE_TRANSIENT));
|
||||
while(sqlite3_step(query) == SQLITE_ROW) {
|
||||
Video video(query);
|
||||
video.tui_title_width = string_width(video.title);
|
||||
channelVideos.push_back(video);
|
||||
}
|
||||
SC(sqlite3_finalize(query));
|
||||
}
|
||||
|
||||
void fetch_videos_for_channel(Channel &ch, bool name_in_title=false)
|
||||
{
|
||||
std::string title("Refreshing");
|
||||
if(name_in_title)
|
||||
title.append(" ").append(ch.name);
|
||||
title.append("…");
|
||||
progress_info *info = begin_progress(title, 30);
|
||||
ch.fetch_new_videos(db, info);
|
||||
end_progress(info);
|
||||
load_videos_for_channel(channels[*selected_channel].id, true);
|
||||
ch.load_info(db);
|
||||
}
|
||||
|
||||
void select_channel_by_index(const int index) {
|
||||
selected_channel = index;
|
||||
const Channel &channel = channels.at(*selected_channel);
|
||||
selected_video = 0;
|
||||
current_video_count = channel.video_count;
|
||||
load_videos_for_channel(channel.id);
|
||||
}
|
||||
|
||||
void select_channel_by_name(const std::string &channel_name) {
|
||||
auto it = std::find_if(channels.cbegin(), channels.cend(), [&](const Channel &channel){ return channel.name == channel_name; });
|
||||
if(it == channels.cend())
|
||||
return;
|
||||
select_channel_by_index(std::distance(channels.cbegin(), it));
|
||||
}
|
||||
|
||||
void select_channel_by_id(const std::string &channel_id) {
|
||||
auto it = std::find_if(channels.cbegin(), channels.cend(), [&](const Channel &channel){ return channel.id == channel_id; });
|
||||
if(it == channels.cend())
|
||||
return;
|
||||
select_channel_by_index(std::distance(channels.cbegin(), it));
|
||||
}
|
||||
|
||||
void add_channel_to_list(Channel &channel)
|
||||
{
|
||||
channel.load_info(db);
|
||||
|
||||
std::string selected_channel_id;
|
||||
if(selected_channel) {
|
||||
selected_channel_id = channels[*selected_channel].id;
|
||||
}
|
||||
|
||||
channels.push_back(channel);
|
||||
std::sort(channels.begin(), channels.end(), [](const Channel &a, const Channel &b){ return a.name < b.name; });
|
||||
|
||||
if(selected_channel) {
|
||||
const size_t new_index = std::distance(channels.cbegin(), std::find_if(channels.cbegin(), channels.cend(), [&](const Channel &ch){ return ch.id == selected_channel_id; }));
|
||||
selected_channel = new_index;
|
||||
}
|
||||
}
|
||||
|
||||
void handle_add_channel_by_name()
|
||||
{
|
||||
std::string channel_name = get_string("Add new Channel", "Enter channel name");
|
||||
if(channel_name.empty()) {
|
||||
return;
|
||||
} else if(std::find_if(channels.cbegin(), channels.cend(), [&](const Channel &channel){ return channel.name == channel_name; }) != channels.cend()) {
|
||||
message_box("Can't add channel", "A channel with this name is already in the list!");
|
||||
return;
|
||||
} else {
|
||||
Channel ch = Channel::add(db, "forUsername", channel_name);
|
||||
if(ch.is_valid()) {
|
||||
add_channel_to_list(ch);
|
||||
select_channel_by_id(ch.id);
|
||||
tp_flush();
|
||||
if(message_box("Update now?", "Fetch videos for this channel now?", Button::Yes | Button::No, Button::Yes) == Button::Yes) {
|
||||
fetch_videos_for_channel(ch);
|
||||
}
|
||||
} else {
|
||||
message_box("Can't add channel", "There is no channel with this name!");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void handle_add_channel_by_id()
|
||||
{
|
||||
std::string channel_id = get_string("Add new Channel", "Enter channel ID");
|
||||
if(channel_id.empty()) {
|
||||
return;
|
||||
} else if(std::find_if(channels.cbegin(), channels.cend(), [&](const Channel &channel){ return channel.id == channel_id; }) != channels.cend()) {
|
||||
message_box("Can't add channel", "A channel with this ID is already in the list!");
|
||||
return;
|
||||
} else {
|
||||
Channel ch = Channel::add(db, "id", channel_id);
|
||||
if(ch.is_valid()) {
|
||||
add_channel_to_list(ch);
|
||||
select_channel_by_id(ch.id);
|
||||
tp_flush();
|
||||
if(message_box("Update now?", "Fetch videos for this channel now?", Button::Yes | Button::No, Button::Yes) == Button::Yes) {
|
||||
fetch_videos_for_channel(ch);
|
||||
}
|
||||
} else {
|
||||
message_box("Can't add channel", "There is no channel with this ID!");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void handle_watch_video(Video &video, bool mark_only) {
|
||||
const std::string url = "https://youtube.com/watch?v=";
|
||||
const std::string cmd = "xdg-open \"" + url + video.id + "\" > /dev/null";
|
||||
if(!mark_only) {
|
||||
int rc = system(cmd.c_str());
|
||||
if(rc)
|
||||
message_box("Failed to open browser", ("xdg-open failed with error " + std::to_string(rc)).c_str());
|
||||
}
|
||||
video.set_flag(db, kWatched);
|
||||
}
|
||||
|
||||
void handle_mark_all_videos_watched(Channel &channel) {
|
||||
sqlite3_exec(db, "BEGIN TRANSACTION;", nullptr, nullptr, nullptr);
|
||||
for(Video &video: videos[channel.id]) {
|
||||
video.set_flag(db, kWatched);
|
||||
}
|
||||
sqlite3_exec(db, "COMMIT TRANSACTION;", nullptr, nullptr, nullptr);
|
||||
channel.load_info(db);
|
||||
}
|
||||
|
||||
void action_select_channel() {
|
||||
if(channels.size()) {
|
||||
std::vector<std::string> names;
|
||||
for(const Channel &c: channels)
|
||||
names.push_back(c.name + " (" + std::to_string(c.unwatched) + ")");
|
||||
const int channel = get_selection("Switch Channel", names, *selected_channel, Align::VCenter | Align::Left);
|
||||
if(channel != -1)
|
||||
select_channel_by_index(channel);
|
||||
} else {
|
||||
message_box("Can't select channel", "No channels configured.\n Please configure one.");
|
||||
}
|
||||
}
|
||||
|
||||
void action_refresh_channel() {
|
||||
Channel &ch = channels[*selected_channel];
|
||||
fetch_videos_for_channel(ch);
|
||||
}
|
||||
|
||||
void action_refresh_all_channels() {
|
||||
if(message_box("Refresh all channels?", ("Do you want to refresh all " + std::to_string(channels.size()) + " channels?").c_str(), Button::Yes | Button::No, Button::No) != Button::Yes)
|
||||
return;
|
||||
for(Channel &channel: channels) {
|
||||
fetch_videos_for_channel(channel, true);
|
||||
}
|
||||
}
|
||||
|
||||
void action_mark_video_watched() {
|
||||
Channel &ch = channels[*selected_channel];
|
||||
Video &video = videos[ch.id][selected_video];
|
||||
video.set_flag(db, kWatched);
|
||||
ch.load_info(db);
|
||||
}
|
||||
|
||||
void action_watch_video() {
|
||||
Channel &ch = channels[*selected_channel];
|
||||
Video &video = videos[ch.id][selected_video];
|
||||
|
||||
const std::string url = "https://youtube.com/watch?v=";
|
||||
const std::string cmd = "xdg-open \"" + url + video.id + "\" > /dev/null";
|
||||
int rc = system(cmd.c_str());
|
||||
if(rc) {
|
||||
message_box("Failed to open browser", ("xdg-open failed with error " + std::to_string(rc)).c_str());
|
||||
} else {
|
||||
video.set_flag(db, kWatched);
|
||||
ch.load_info(db);
|
||||
}
|
||||
}
|
||||
|
||||
void action_mark_video_unwatched() {
|
||||
Channel &ch = channels[*selected_channel];
|
||||
Video &selected = videos[ch.id][selected_video];
|
||||
selected.set_flag(db, kWatched, false);
|
||||
ch.load_info(db);
|
||||
}
|
||||
|
||||
void action_mark_all_videos_watched() {
|
||||
Channel &ch = channels[*selected_channel];
|
||||
if(message_box("Mark all as watched", ("Do you want to mark all videos of " + ch.name + " as watched?").c_str(), Button::Yes | Button::No, Button::No) != Button::Yes)
|
||||
return;
|
||||
handle_mark_all_videos_watched(ch);
|
||||
}
|
||||
|
||||
void action_select_prev_channel() {
|
||||
if(selected_channel && *selected_channel > 0)
|
||||
select_channel_by_index(*selected_channel - 1);
|
||||
}
|
||||
|
||||
void action_select_next_channel() {
|
||||
if(selected_channel && *selected_channel < channels.size() - 1)
|
||||
select_channel_by_index(*selected_channel + 1);
|
||||
}
|
||||
|
||||
void action_select_prev_video() {
|
||||
if(selected_video > 0)
|
||||
selected_video--;
|
||||
}
|
||||
|
||||
void action_select_next_video() {
|
||||
if(selected_video < current_video_count - 1)
|
||||
selected_video++;
|
||||
}
|
||||
|
||||
void action_select_prev_video_page() {
|
||||
if(selected_video > 0)
|
||||
selected_video -= std::min(selected_video, videos_per_page);
|
||||
}
|
||||
|
||||
void action_select_next_video_page() {
|
||||
if(selected_video < current_video_count - 1)
|
||||
selected_video++;
|
||||
}
|
||||
|
||||
void action_select_first_video() {
|
||||
selected_video = 0;
|
||||
}
|
||||
|
||||
void action_select_last_video() {
|
||||
selected_video = current_video_count - 1;
|
||||
}
|
||||
|
||||
void action_scroll_title_left() {
|
||||
if(title_offset > 0)
|
||||
title_offset--;
|
||||
}
|
||||
|
||||
void action_scroll_title_right() {
|
||||
if(any_title_in_next_half)
|
||||
title_offset++;
|
||||
}
|
||||
|
||||
void action_show_video_detail() {
|
||||
message_box("Details", "Video details go here...");
|
||||
}
|
||||
|
||||
using json = nlohmann::json;
|
||||
std::optional<json> load_json(const std::string &filename) {
|
||||
std::ifstream ifs(filename);
|
||||
if(!ifs.is_open())
|
||||
return {};
|
||||
try {
|
||||
json data;
|
||||
ifs >> data;
|
||||
return data;
|
||||
} catch (json::parse_error &err) {
|
||||
fprintf(stderr, "Failed to parse JSON file %s: %s\n", filename.c_str(), err.what());
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
bool save_json(const std::string &filename, const json &data) {
|
||||
std::ofstream ofs(filename);
|
||||
if(!ofs.is_open())
|
||||
return false;
|
||||
try {
|
||||
ofs << data;
|
||||
return true;
|
||||
} catch (json::exception &err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
int main()
|
||||
{
|
||||
user_home = std::string(std::getenv("HOME"));
|
||||
std::vector<std::string> config_locations{user_home + "/.config/", "./"};
|
||||
|
||||
curl_global_init(CURL_GLOBAL_ALL);
|
||||
tp_init();
|
||||
|
||||
nlohmann::json config;
|
||||
for(const std::string &location: config_locations) {
|
||||
std::string config_file(location + "yttui.conf");
|
||||
auto config_data = load_json(config_file);
|
||||
if(config_data) {
|
||||
config = *config_data;
|
||||
fprintf(stderr, "Using config file %s...\n", config_file.c_str());
|
||||
break;
|
||||
} else {
|
||||
fprintf(stderr, "Can't read config file %s...\n", config_file.c_str());
|
||||
}
|
||||
}
|
||||
if(!config.empty()) {
|
||||
if(config.contains("apiKey") && config["apiKey"].is_string()) {
|
||||
yt_config.api_key = config["apiKey"];
|
||||
} else {
|
||||
tui_abort("A YouTube API key is required for this application to function.\n Please provide one in the config file.");
|
||||
}
|
||||
if(config.contains("extraHeaders") && config["extraHeaders"].is_array()) {
|
||||
for(const json &elem: config["extraHeaders"]) {
|
||||
if(elem.contains("key") && elem["key"].is_string() && elem.contains("value") && elem["value"].is_string()) {
|
||||
yt_config.extra_headers.emplace(elem["key"], elem["value"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sqlite3_open("yttool.sqlite", &db);
|
||||
|
||||
sqlite3_stmt *query;
|
||||
sqlite3_prepare_v2(db, "SELECT * FROM channels;", -1, &query, nullptr);
|
||||
while(sqlite3_step(query) == SQLITE_ROW) {
|
||||
Channel channel(query);
|
||||
add_channel_to_list(channel);
|
||||
}
|
||||
sqlite3_finalize(query);
|
||||
|
||||
if(channels.size())
|
||||
select_channel_by_index(0);
|
||||
|
||||
bool exit = false;
|
||||
std::vector<action> actions = {
|
||||
{TERMPAINT_EV_CHAR, "a", 0, handle_add_channel_by_name, "Add channel by name"},
|
||||
{TERMPAINT_EV_CHAR, "A", 0, handle_add_channel_by_id, "Add channel by Id"},
|
||||
{TERMPAINT_EV_CHAR, "c", 0, action_select_channel, "Select channel"},
|
||||
{TERMPAINT_EV_CHAR, "j", 0, action_select_prev_channel, "Select previous channel"},
|
||||
{TERMPAINT_EV_CHAR, "k", 0, action_select_next_channel, "Select next channel"},
|
||||
{TERMPAINT_EV_CHAR, "r", 0, action_refresh_channel, "Refresh selected channel"},
|
||||
{TERMPAINT_EV_CHAR, "R", 0, action_refresh_all_channels, "Refresh all channels"},
|
||||
{TERMPAINT_EV_CHAR, "w", 0, action_watch_video, "Watch video"},
|
||||
{TERMPAINT_EV_CHAR, "w", TERMPAINT_MOD_ALT, action_mark_video_watched, "Mark video as watched"},
|
||||
{TERMPAINT_EV_CHAR, "u", 0, action_mark_video_unwatched, "Mark video as unwatched"},
|
||||
{TERMPAINT_EV_CHAR, "W", 0, action_mark_all_videos_watched, "Mark channel as watched"},
|
||||
{TERMPAINT_EV_CHAR, "q", TERMPAINT_MOD_CTRL, [&](){exit = true;}, "Quit"},
|
||||
|
||||
{TERMPAINT_EV_KEY, "Space", 0, action_show_video_detail, "Show video details"},
|
||||
{TERMPAINT_EV_KEY, "ArrowUp", 0, action_select_prev_video, "Previous video"},
|
||||
{TERMPAINT_EV_KEY, "ArrowDown", 0, action_select_next_video, "Next video"},
|
||||
{TERMPAINT_EV_KEY, "PageUp", 0, action_select_prev_video_page, "Previous video page"},
|
||||
{TERMPAINT_EV_KEY, "PageDown", 0, action_select_next_video_page, "Next video page"},
|
||||
{TERMPAINT_EV_KEY, "Home", 0, action_select_first_video, "First video"},
|
||||
{TERMPAINT_EV_KEY, "End", 0, action_select_last_video, "Last video"},
|
||||
{TERMPAINT_EV_KEY, "ArrowLeft", 0, action_scroll_title_left, "Scroll title left"},
|
||||
{TERMPAINT_EV_KEY, "ArrowRight", 0, action_scroll_title_right, "Scroll title right"},
|
||||
};
|
||||
|
||||
do {
|
||||
termpaint_surface_clear(surface, TERMPAINT_DEFAULT_COLOR, TERMPAINT_DEFAULT_COLOR);
|
||||
if(selected_channel)
|
||||
draw_channel_list(videos[channels.at(*selected_channel).id]);
|
||||
else
|
||||
draw_no_channels_msg();
|
||||
tp_flush();
|
||||
|
||||
auto event = tp_wait_for_event();
|
||||
if(!event)
|
||||
abort();
|
||||
|
||||
handle_action(*event, actions);
|
||||
} while (!exit);
|
||||
|
||||
tp_shutdown();
|
||||
sqlite3_close(db);
|
||||
curl_global_cleanup();
|
||||
|
||||
return 0;
|
||||
}
|
|
@ -0,0 +1,537 @@
|
|||
#include "tui.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <deque>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
termpaint_integration *integration;
|
||||
termpaint_terminal *terminal;
|
||||
termpaint_surface *surface;
|
||||
|
||||
AttributeSet attributes[ASetTypeCount];
|
||||
|
||||
std::deque<Event> eventqueue;
|
||||
|
||||
static void convert_tp_event(void *, termpaint_event *tp_event) {
|
||||
Event e;
|
||||
if (tp_event->type == TERMPAINT_EV_CHAR) {
|
||||
e.type = tp_event->type;
|
||||
e.modifier = tp_event->c.modifier;
|
||||
e.string = std::string(tp_event->c.string, tp_event->c.length);
|
||||
eventqueue.push_back(e);
|
||||
} else if (tp_event->type == TERMPAINT_EV_KEY) {
|
||||
e.type = tp_event->type;
|
||||
e.modifier = tp_event->key.modifier;
|
||||
e.string = std::string(tp_event->key.atom, tp_event->key.length);
|
||||
eventqueue.push_back(e);
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<Event> wait_for_event(termpaint_integration *integration, int timeout) {
|
||||
while (eventqueue.empty()) {
|
||||
bool ok = false;
|
||||
if(timeout > 0)
|
||||
ok = termpaintx_full_integration_do_iteration_with_timeout(integration, &timeout);
|
||||
else
|
||||
ok = termpaintx_full_integration_do_iteration(integration);
|
||||
if (!ok) {
|
||||
return {}; // or some other error handling
|
||||
} else if(timeout == 0) {
|
||||
Event e;
|
||||
e.type = EV_TIMEOUT;
|
||||
eventqueue.push_back(e);
|
||||
}
|
||||
}
|
||||
Event e = eventqueue.front();
|
||||
eventqueue.pop_front();
|
||||
return e;
|
||||
}
|
||||
|
||||
void tp_init()
|
||||
{
|
||||
auto new_attr_set = [](AttributeSet &set, int color, int style = 0) {
|
||||
set.normal = termpaint_attr_new(color, TERMPAINT_DEFAULT_COLOR);
|
||||
termpaint_attr_set_style(set.normal, style);
|
||||
set.highlight = termpaint_attr_clone(set.normal);
|
||||
termpaint_attr_set_style(set.highlight, TERMPAINT_STYLE_INVERSE | style);
|
||||
};
|
||||
|
||||
new_attr_set(attributes[ASNormal], TERMPAINT_DEFAULT_COLOR);
|
||||
new_attr_set(attributes[ASWatched], TERMPAINT_DEFAULT_COLOR);
|
||||
new_attr_set(attributes[ASUnwatched], TERMPAINT_DEFAULT_COLOR, TERMPAINT_STYLE_BOLD);
|
||||
|
||||
integration = termpaintx_full_integration_setup_terminal_fullscreen( "+kbdsig +kbdsigint +kbdsigtstp", convert_tp_event, nullptr, &terminal);
|
||||
surface = termpaint_terminal_get_surface(terminal);
|
||||
termpaint_terminal_set_cursor_visible(terminal, false);
|
||||
}
|
||||
|
||||
void tp_shutdown()
|
||||
{
|
||||
auto free_attr_set = [](AttributeSet &set) {
|
||||
termpaint_attr_free(set.normal);
|
||||
termpaint_attr_free(set.highlight);
|
||||
};
|
||||
|
||||
free_attr_set(attributes[ASNormal]);
|
||||
free_attr_set(attributes[ASWatched]);
|
||||
free_attr_set(attributes[ASUnwatched]);
|
||||
|
||||
termpaint_terminal_free_with_restore(terminal);
|
||||
}
|
||||
|
||||
void tp_flush()
|
||||
{
|
||||
termpaint_terminal_flush(terminal, false);
|
||||
}
|
||||
|
||||
std::optional<Event> tp_wait_for_event()
|
||||
{
|
||||
return wait_for_event(integration, 0);
|
||||
}
|
||||
|
||||
static std::string repeated(const int n, const std::string &what)
|
||||
{
|
||||
std::string out;
|
||||
for(int i=0; i<n; i++)
|
||||
out.append(what);
|
||||
return out;
|
||||
}
|
||||
|
||||
size_t string_width(const std::string &str)
|
||||
{
|
||||
termpaint_text_measurement *m = termpaint_text_measurement_new(surface);
|
||||
termpaint_text_measurement_feed_utf8(m, str.c_str(), str.length(), true);
|
||||
const int width = termpaint_text_measurement_last_width(m);
|
||||
termpaint_text_measurement_free(m);
|
||||
return width;
|
||||
}
|
||||
|
||||
static void resolve_align(const Align align, const int width, const int height, const int xmin, const int xmax, const int ymin, const int ymax, int &x, int &y)
|
||||
{
|
||||
int available_width = xmax-xmin;
|
||||
x = xmin;
|
||||
if(((int)align & 0x0f) == (int)Align::HCenter)
|
||||
x = (available_width - width) / 2;
|
||||
else if(((int)align & 0x0f) == (int)Align::Right)
|
||||
x = xmax - width;
|
||||
|
||||
int available_rows = ymax - ymin;
|
||||
y = ymin;
|
||||
if(((int)align & 0xf0) == (int)Align::VCenter)
|
||||
y = (available_rows - height) / 2;
|
||||
else if(((int)align & 0xf0) == (int)Align::Bottom)
|
||||
y = ymax - height;
|
||||
}
|
||||
|
||||
static void draw_box_with_caption(int x, int y, int w, int h, const std::string &caption=std::string())
|
||||
{
|
||||
termpaint_surface_clear_rect(surface, x, y, w, h, TERMPAINT_DEFAULT_COLOR, TERMPAINT_DEFAULT_COLOR);
|
||||
const int fill = w - 2;
|
||||
const int endy = y+h;
|
||||
|
||||
for(int yy = y; yy<endy; yy++)
|
||||
{
|
||||
std::string s;
|
||||
if(yy == y) {
|
||||
s = "┌" + repeated(fill, "─") + "┐";
|
||||
} else if(yy + 1 == endy) {
|
||||
s = "└" + repeated(fill, "─") + "┘";
|
||||
} else {
|
||||
s = "│" + repeated(fill, " ") + "│";
|
||||
}
|
||||
termpaint_surface_write_with_attr(surface, x, yy, s.c_str(), attributes[ASNormal].normal);
|
||||
}
|
||||
if(!caption.empty())
|
||||
termpaint_surface_write_with_attr(surface, x+2, y, caption.c_str(), attributes[ASNormal].normal);
|
||||
}
|
||||
|
||||
int get_selection(const std::string &caption, const std::vector<std::string> &entries, size_t selected, const Align align)
|
||||
{
|
||||
selected = std::max(size_t(0), std::min(entries.size(), selected));
|
||||
|
||||
size_t cols_needed = string_width(caption);
|
||||
for(const auto &e: entries)
|
||||
cols_needed = std::max(cols_needed, string_width(e));
|
||||
|
||||
cols_needed += 4; // Border and left/right padding
|
||||
|
||||
const int rows_needed = entries.size()+2; // Number of entries and top/bottom border
|
||||
|
||||
const int cols = termpaint_surface_width(surface);
|
||||
const int rows = termpaint_surface_height(surface);
|
||||
|
||||
int x, y;
|
||||
resolve_align(align, cols_needed, rows_needed, 0, cols, 0, rows, x, y);
|
||||
|
||||
while (true) {
|
||||
draw_box_with_caption(x, y, cols_needed, rows_needed, caption);
|
||||
int yy = y+1;
|
||||
|
||||
size_t cur_entry = 0;
|
||||
for(const auto &e: entries) {
|
||||
termpaint_attr *attr = cur_entry == selected ? attributes[ASNormal].highlight : attributes[ASNormal].normal;
|
||||
termpaint_surface_write_with_attr(surface, x+2, yy++, e.c_str(), attr);
|
||||
cur_entry++;
|
||||
}
|
||||
termpaint_terminal_flush(terminal, false);
|
||||
|
||||
auto event = wait_for_event(integration, 0);
|
||||
if(!event)
|
||||
abort();
|
||||
|
||||
if(event->type == TERMPAINT_EV_CHAR) {
|
||||
if(event->string.length() == 1) {
|
||||
char c = event->string[0];
|
||||
if(c>'0' && c<='9') {
|
||||
size_t idx = c - '0';
|
||||
if(idx > entries.size())
|
||||
continue;
|
||||
return idx - 1;
|
||||
}
|
||||
}
|
||||
} else if (event->type == TERMPAINT_EV_KEY) {
|
||||
if (event->string == "Escape") {
|
||||
return -1;
|
||||
} else if(event->string == "ArrowUp") {
|
||||
if(selected > 0)
|
||||
selected--;
|
||||
} else if(event->string == "ArrowDown") {
|
||||
if(selected < entries.size() - 1)
|
||||
selected++;
|
||||
} else if(event->string == "Enter" || event->string == "NumpadEnter") {
|
||||
return static_cast<int>(selected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
Align operator|(const Align &a, const Align &b)
|
||||
{
|
||||
return Align((int)a | (int)b);
|
||||
}
|
||||
|
||||
Button operator|(const Button &a, const Button &b)
|
||||
{
|
||||
return Button((int)a | (int)b);
|
||||
}
|
||||
|
||||
Button operator&(const Button &a, const Button &b)
|
||||
{
|
||||
return Button((int)a & (int)b);
|
||||
}
|
||||
|
||||
std::string get_string(const std::string &caption, const std::string &text, const Align align)
|
||||
{
|
||||
const int cols = termpaint_surface_width(surface);
|
||||
const int rows = termpaint_surface_height(surface);
|
||||
|
||||
const size_t rows_needed = 3 + !text.empty();
|
||||
const size_t cols_needed = cols/2;
|
||||
|
||||
int x, y;
|
||||
resolve_align(align, cols_needed, rows_needed, 0, cols, 0, rows, x, y);
|
||||
|
||||
const int input_row = y + 1 + !text.empty();
|
||||
|
||||
std::string input;
|
||||
size_t input_pos = 0;
|
||||
termpaint_terminal_set_cursor_visible(terminal, true);
|
||||
termpaint_terminal_set_cursor_style(terminal, TERMPAINT_CURSOR_STYLE_BAR, true);
|
||||
|
||||
while(true) {
|
||||
draw_box_with_caption(x, y, cols_needed, rows_needed, caption);
|
||||
if(!text.empty())
|
||||
termpaint_surface_write_with_attr(surface, x + 1, y + 1, text.c_str(), attributes[ASNormal].normal);
|
||||
termpaint_surface_write_with_attr(surface, x + 1, input_row, input.c_str(), attributes[ASNormal].normal);
|
||||
termpaint_terminal_set_cursor_position(terminal, x + 1 + input_pos, input_row);
|
||||
|
||||
termpaint_terminal_flush(terminal, false);
|
||||
|
||||
auto event = wait_for_event(integration, 0);
|
||||
if(!event)
|
||||
abort();
|
||||
|
||||
if(event->type == TERMPAINT_EV_CHAR) {
|
||||
if(event->string == "a" && event->modifier & TERMPAINT_MOD_CTRL) {
|
||||
input_pos = 0;
|
||||
} else if (event->string == "e" && event->modifier & TERMPAINT_MOD_CTRL) {
|
||||
input_pos = input.size();
|
||||
} else {
|
||||
if(input_pos + 1 == cols_needed - 1)
|
||||
continue;
|
||||
input.insert(input_pos, event->string);
|
||||
input_pos++;
|
||||
}
|
||||
} else if (event->type == TERMPAINT_EV_KEY) {
|
||||
if (event->string == "Escape") {
|
||||
input.clear();
|
||||
break;
|
||||
} else if(event->string == "Backspace") {
|
||||
if(!input.empty()) {
|
||||
input.erase(input_pos - 1, 1);
|
||||
input_pos--;
|
||||
}
|
||||
} else if(event->string == "Delete") {
|
||||
if(input_pos < input.size()) {
|
||||
input.erase(input_pos, 1);
|
||||
}
|
||||
} else if(event->string == "ArrowLeft") {
|
||||
if(input_pos > 0)
|
||||
input_pos--;
|
||||
} else if(event->string == "ArrowRight") {
|
||||
if(input_pos < input.size())
|
||||
input_pos++;
|
||||
} else if(event->string == "Enter" || event->string == "NumpadEnter") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
termpaint_terminal_set_cursor_visible(terminal, false);
|
||||
termpaint_terminal_set_cursor_style(terminal, TERMPAINT_CURSOR_STYLE_TERM_DEFAULT, false);
|
||||
return input;
|
||||
}
|
||||
|
||||
std::vector<std::string> split(const std::string &str, const char delim, const unsigned int max_splits=0)
|
||||
{
|
||||
std::vector<std::string> parts;
|
||||
|
||||
std::string::const_iterator prev = str.cbegin();
|
||||
std::string::const_iterator cur = str.cbegin();
|
||||
unsigned int splits_done = 0;
|
||||
|
||||
do {
|
||||
cur = std::find(prev, str.cend(), delim);
|
||||
if(max_splits!=0 && splits_done>=max_splits) {
|
||||
parts.emplace_back(prev, str.cend());
|
||||
break;
|
||||
}
|
||||
parts.emplace_back(prev, cur);
|
||||
splits_done++;
|
||||
if(cur!=str.cend())
|
||||
cur++;
|
||||
prev = cur;
|
||||
} while(cur!=str.cend());
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
struct button_info {
|
||||
Button button;
|
||||
std::string string;
|
||||
size_t width;
|
||||
};
|
||||
|
||||
static std::vector<button_info> all_buttons = {
|
||||
{Button::Ok, "Ok", 2},
|
||||
{Button::Cancel, "Cancel", 6},
|
||||
{Button::Yes, "Yes", 3},
|
||||
{Button::No, "No", 2},
|
||||
};
|
||||
static const char *button_gfx_left[]={"[", " "};
|
||||
const char *button_gfx_right[]={"]", " "};
|
||||
|
||||
Button message_box(const std::string &caption, const std::string &text, const Button buttons, const Button default_button, const Align align)
|
||||
{
|
||||
std::vector<button_info> active_buttons;
|
||||
|
||||
size_t buttons_width = 0;
|
||||
for(const button_info &info: all_buttons) {
|
||||
if((buttons & info.button) == info.button) {
|
||||
active_buttons.push_back(info);
|
||||
buttons_width += info.width + 2 + 1;
|
||||
}
|
||||
}
|
||||
if(buttons_width)
|
||||
buttons_width -= 1;
|
||||
size_t selected_button = std::distance(active_buttons.begin(),
|
||||
std::find_if(active_buttons.begin(), active_buttons.end(),
|
||||
[default_button](const button_info &info){ return info.button == default_button; }));
|
||||
|
||||
size_t width = std::max(string_width(caption), buttons_width);
|
||||
|
||||
const std::vector<std::string> lines = split(text, '\n');
|
||||
for(const std::string &line: lines) {
|
||||
width = std::max(width, string_width(line));
|
||||
}
|
||||
|
||||
const size_t cols = termpaint_surface_width(surface);
|
||||
const size_t rows = termpaint_surface_height(surface);
|
||||
|
||||
const size_t rows_needed = 4 + lines.size();
|
||||
const size_t cols_needed = width + 4;
|
||||
|
||||
int x, y;
|
||||
resolve_align(align, cols_needed, rows_needed, 0, cols, 0, rows, x, y);
|
||||
|
||||
while(true) {
|
||||
draw_box_with_caption(x, y, cols_needed, rows_needed, caption);
|
||||
|
||||
for(size_t i=0; i<lines.size(); i++) {
|
||||
termpaint_surface_write_with_attr(surface, x + 2, y + 1 + i, lines[i].c_str(), attributes[ASNormal].normal);
|
||||
}
|
||||
|
||||
|
||||
int button_x = x + 2;
|
||||
for(size_t btn=0; btn<active_buttons.size(); btn++) {
|
||||
const bool button_selected = selected_button == btn;
|
||||
termpaint_attr *attr = button_selected ? attributes[ASNormal].highlight : attributes[ASNormal].normal;
|
||||
|
||||
termpaint_surface_write_with_attr(surface, button_x, y + rows_needed - 2, button_gfx_left[button_selected], attr);
|
||||
termpaint_surface_write_with_attr(surface, button_x + 1, y + rows_needed - 2, active_buttons[btn].string.c_str(), attr);
|
||||
termpaint_surface_write_with_attr(surface, button_x + active_buttons[btn].width + 1, y + rows_needed - 2, button_gfx_right[button_selected], attr);
|
||||
|
||||
button_x += active_buttons[btn].width + 2 + 1;
|
||||
}
|
||||
|
||||
termpaint_terminal_flush(terminal, false);
|
||||
|
||||
auto event = wait_for_event(integration, 0);
|
||||
if(!event)
|
||||
abort();
|
||||
|
||||
if (event->type == TERMPAINT_EV_KEY) {
|
||||
if (event->string == "Escape" || event->string == "Enter" || event->string == "NumpadEnter") {
|
||||
break;
|
||||
} else if(event->string == "ArrowLeft" && selected_button > 0) {
|
||||
selected_button--;
|
||||
} else if(event->string == "ArrowRight" && selected_button < active_buttons.size() - 1) {
|
||||
selected_button++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return active_buttons[selected_button].button;
|
||||
}
|
||||
|
||||
void tp_pause()
|
||||
{
|
||||
termpaint_terminal_pause(terminal);
|
||||
}
|
||||
|
||||
void tp_unpause()
|
||||
{
|
||||
termpaint_terminal_unpause(terminal);
|
||||
}
|
||||
|
||||
struct progress_info
|
||||
{
|
||||
Align align;
|
||||
size_t width, height;
|
||||
std::string caption;
|
||||
int value, maxvalue;
|
||||
};
|
||||
|
||||
static void draw_progress(progress_info *info)
|
||||
{
|
||||
const size_t cols = termpaint_surface_width(surface);
|
||||
const size_t rows = termpaint_surface_height(surface);
|
||||
|
||||
int x, y;
|
||||
resolve_align(info->align, info->width, info->height, 0, cols, 0, rows, x, y);
|
||||
|
||||
draw_box_with_caption(x, y, info->width, info->height, info->caption);
|
||||
|
||||
size_t progress_w = info->width - 4;
|
||||
float progress = info->value / (float)info->maxvalue;
|
||||
int full_blocks = progress_w * progress;
|
||||
int partial_block = ((progress_w * progress) - full_blocks) * 8;
|
||||
|
||||
std::string draw = repeated(full_blocks, "█");
|
||||
|
||||
switch(partial_block) {
|
||||
case 1:
|
||||
draw.append("▏"); break;
|
||||
case 2:
|
||||
draw.append("▎"); break;
|
||||
case 3:
|
||||
draw.append("▍"); break;
|
||||
case 4:
|
||||
draw.append("▌"); break;
|
||||
case 5:
|
||||
draw.append("▋"); break;
|
||||
case 6:
|
||||
draw.append("▊"); break;
|
||||
case 7:
|
||||
draw.append("▉"); break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
termpaint_surface_write_with_attr(surface, x + 2, y + 1, draw.c_str(), attributes[ASNormal].normal);
|
||||
termpaint_terminal_flush(terminal, false);
|
||||
}
|
||||
|
||||
progress_info* begin_progress(const std::string &caption, const int width, const Align align)
|
||||
{
|
||||
progress_info *info = new progress_info;
|
||||
|
||||
info->align = align;
|
||||
info->width = width + 4;
|
||||
info->height = 3;
|
||||
info->caption = caption;
|
||||
info->value = 0;
|
||||
info->maxvalue = 100;
|
||||
|
||||
draw_progress(info);
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
void update_progress(progress_info *info, int value, int maxvalue)
|
||||
{
|
||||
info->value = value;
|
||||
info->maxvalue = maxvalue;
|
||||
draw_progress(info);
|
||||
}
|
||||
|
||||
void end_progress(progress_info *info)
|
||||
{
|
||||
delete info;
|
||||
}
|
||||
|
||||
std::pair<size_t, size_t> string_size(const std::string &str)
|
||||
{
|
||||
size_t width = 0;
|
||||
const std::vector<std::string> lines = split(str, '\n');
|
||||
for(const std::string &line: lines) {
|
||||
width = std::max(width, string_width(line));
|
||||
}
|
||||
return {width, lines.size()};
|
||||
}
|
||||
|
||||
void write_multiline_string(const int x, const int y, const std::string &str, termpaint_attr *attr)
|
||||
{
|
||||
const std::vector<std::string> lines = split(str, '\n');
|
||||
for(size_t i=0; i<lines.size(); i++) {
|
||||
termpaint_surface_write_with_attr(surface, x, y + i, lines[i].c_str(), attr);
|
||||
}
|
||||
}
|
||||
|
||||
static std::string simple_wrap(const std::string &text, const size_t desired_width)
|
||||
{
|
||||
std::string out;
|
||||
size_t current_line_width = 0;
|
||||
for(const std::string &word: split(text, ' ')) {
|
||||
size_t w = string_width(word);
|
||||
if(current_line_width + w < desired_width) {
|
||||
out.append(word).append(" ");
|
||||
} else {
|
||||
current_line_width = 0;
|
||||
out.append("\n").append(word).append(" ");
|
||||
}
|
||||
current_line_width += w + 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
void tui_abort(std::string message)
|
||||
{
|
||||
const size_t cols = termpaint_surface_width(surface);
|
||||
|
||||
message_box("Error", simple_wrap(message, cols/2));
|
||||
exit(1);
|
||||
}
|
|
@ -0,0 +1,74 @@
|
|||
#pragma once
|
||||
|
||||
#include <termpaintx.h>
|
||||
#define EV_TIMEOUT 0xffff
|
||||
|
||||
#include <functional>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
struct Event {
|
||||
int type;
|
||||
int modifier;
|
||||
std::string string;
|
||||
};
|
||||
|
||||
enum class Align {
|
||||
HCenter = 0x01,
|
||||
Left = 0x02,
|
||||
Right = 0x04,
|
||||
|
||||
VCenter = 0x10,
|
||||
Top = 0x20,
|
||||
Bottom = 0x40,
|
||||
|
||||
Center = HCenter | VCenter,
|
||||
};
|
||||
extern Align operator|(const Align &a, const Align &b);
|
||||
|
||||
extern termpaint_surface *surface;
|
||||
|
||||
enum AttributeSetType {
|
||||
ASNormal,
|
||||
ASWatched,
|
||||
ASUnwatched,
|
||||
ASetTypeCount,
|
||||
};
|
||||
|
||||
struct AttributeSet {
|
||||
termpaint_attr *normal;
|
||||
termpaint_attr *highlight;
|
||||
};
|
||||
extern AttributeSet attributes[ASetTypeCount];
|
||||
|
||||
enum class Button {
|
||||
Ok = (1<<0),
|
||||
Cancel = (1<<1),
|
||||
Yes = (1<<2),
|
||||
No = (1<<3),
|
||||
};
|
||||
extern Button operator|(const Button &a, const Button &b);
|
||||
extern Button operator&(const Button &a, const Button &b);
|
||||
|
||||
extern void tp_init();
|
||||
extern void tp_shutdown();
|
||||
extern void tp_flush();
|
||||
extern void tp_pause();
|
||||
extern void tp_unpause();
|
||||
extern std::optional<Event> tp_wait_for_event();
|
||||
|
||||
extern size_t string_width(const std::string &str);
|
||||
extern std::pair<size_t, size_t> string_size(const std::string &str);
|
||||
extern void write_multiline_string(const int x, const int y, const std::string &str, termpaint_attr *attr);
|
||||
|
||||
extern Button message_box(const std::string &caption, const std::string &text, const Button buttons=Button::Ok, const Button default_button=Button::Ok, const Align align=Align::Center);
|
||||
extern int get_selection(const std::string &caption, const std::vector<std::string> &choices, size_t selected=0, const Align align=Align::Center);
|
||||
extern std::string get_string(const std::string &caption, const std::string &text=std::string(), const Align align=Align::Center);
|
||||
|
||||
struct progress_info;
|
||||
extern progress_info* begin_progress(const std::string &caption, const int width, const Align align=Align::Center);
|
||||
extern void update_progress(progress_info *info, const int val, const int maxval);
|
||||
extern void end_progress(progress_info *info);
|
||||
|
||||
extern void tui_abort(std::string message);
|
|
@ -0,0 +1,23 @@
|
|||
TEMPLATE = app
|
||||
CONFIG += console c++17 sanitizer sanitize_address
|
||||
CONFIG -= app_bundle
|
||||
CONFIG -= qt
|
||||
|
||||
unix:!macx: LIBS += -L$$PWD/termpaint/ -ltermpaint
|
||||
|
||||
INCLUDEPATH += $$PWD/termpaint
|
||||
DEPENDPATH += $$PWD/termpaint
|
||||
|
||||
unix:!macx: PRE_TARGETDEPS += $$PWD/termpaint/libtermpaint.a
|
||||
|
||||
SOURCES += \
|
||||
main.cpp \
|
||||
tui.cpp \
|
||||
yt.cpp
|
||||
|
||||
HEADERS += \
|
||||
tui.h \
|
||||
yt.h
|
||||
|
||||
unix: CONFIG += link_pkgconfig
|
||||
unix: PKGCONFIG += libcurl sqlite3 nlohmann_json
|
|
@ -0,0 +1,266 @@
|
|||
#include "yt.h"
|
||||
|
||||
#include <sqlite3.h>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <curl/curl.h>
|
||||
|
||||
#include "tui.h"
|
||||
|
||||
#define SC(x) { const int res = (x); if(res != SQLITE_OK && res != SQLITE_ROW && res != SQLITE_DONE) { fprintf(stderr, "%s failed: (%d) %s\n", #x, res, sqlite3_errstr(res)); std::abort(); }}
|
||||
|
||||
using json = nlohmann::json;
|
||||
struct yt_config yt_config;
|
||||
|
||||
static std::string get_string(sqlite3_stmt *row, int col)
|
||||
{
|
||||
return std::string((char*)sqlite3_column_text(row, col));
|
||||
}
|
||||
|
||||
static size_t curl_writecallback(void *data, size_t size, size_t nmemb, void *userp)
|
||||
{
|
||||
size_t to_add = size * nmemb;
|
||||
std::vector<unsigned char> *buffer = reinterpret_cast<std::vector<unsigned char>*>(userp);
|
||||
const size_t cur_size = buffer->size();
|
||||
buffer->resize(cur_size + to_add);
|
||||
memcpy(buffer->data() + cur_size, data, to_add);
|
||||
|
||||
return to_add;
|
||||
}
|
||||
|
||||
static json api_request(const std::string &url, std::map<std::string, std::string> params)
|
||||
{
|
||||
CURL *curl = curl_easy_init();
|
||||
curl_slist *headers = nullptr;
|
||||
for(const auto &[header, value]: yt_config.extra_headers) {
|
||||
headers = curl_slist_append(headers, (header + ": " + value).c_str());
|
||||
}
|
||||
|
||||
CURLU *u = curl_url();
|
||||
curl_url_set(u, CURLUPART_URL, url.c_str(), 0);
|
||||
for(const auto &[k, v]: params) {
|
||||
std::string p = k + "=" + v;
|
||||
curl_url_set(u, CURLUPART_QUERY, p.c_str(), CURLU_APPENDQUERY|CURLU_URLENCODE);
|
||||
}
|
||||
char *real_url;
|
||||
curl_url_get(u, CURLUPART_URL, &real_url, 0);
|
||||
|
||||
std::vector<unsigned char> data;
|
||||
|
||||
curl_easy_setopt(curl, CURLOPT_URL, real_url);
|
||||
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, curl_writecallback);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, (void *)&data);
|
||||
|
||||
//curl_easy_setopt(curl, CURLOPT_VERBOSE, 1L);
|
||||
curl_easy_perform(curl);
|
||||
|
||||
data.push_back(0);
|
||||
|
||||
int http_response = 0;
|
||||
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_response);
|
||||
|
||||
curl_free(real_url);
|
||||
curl_url_cleanup(u);
|
||||
curl_easy_cleanup(curl);
|
||||
if(headers)
|
||||
curl_slist_free_all(headers);
|
||||
|
||||
try {
|
||||
return json::parse(data);
|
||||
} catch (json::exception &err) {
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
Channel::Channel(sqlite3_stmt *row): id(get_string(row, 0)), name(get_string(row, 1))
|
||||
{
|
||||
}
|
||||
|
||||
Channel::Channel(const std::string &id, const std::string &name): id(id), name(name)
|
||||
{
|
||||
}
|
||||
|
||||
Channel Channel::add(sqlite3 *db, const std::string &selector, const std::string &value)
|
||||
{
|
||||
std::map<std::string, std::string> params = {
|
||||
{"part", "snippet"},
|
||||
{selector, value},
|
||||
{"key", yt_config.api_key},
|
||||
};
|
||||
|
||||
const json response = api_request("https://content.googleapis.com/youtube/v3/channels", params);
|
||||
|
||||
const json page_info = response["pageInfo"];
|
||||
const bool any_results = page_info["totalResults"] > 0;
|
||||
|
||||
if(!any_results) {
|
||||
return Channel("", "");
|
||||
}
|
||||
|
||||
const std::string channel_id = response["items"][0]["id"];
|
||||
const std::string channel_name = response["items"][0]["snippet"]["title"];
|
||||
|
||||
sqlite3_stmt *query;
|
||||
SC(sqlite3_prepare_v2(db, "INSERT INTO channels(channelId, name) VALUES(?1, ?2);", -1, &query, nullptr));
|
||||
SC(sqlite3_bind_text(query, 1, channel_id.c_str(), -1, SQLITE_TRANSIENT));
|
||||
SC(sqlite3_bind_text(query, 2, channel_name.c_str(), -1, SQLITE_TRANSIENT));
|
||||
sqlite3_step(query);
|
||||
SC(sqlite3_finalize(query));
|
||||
|
||||
return Channel(channel_id, channel_name);
|
||||
}
|
||||
|
||||
std::string Channel::upload_playlist() const
|
||||
{
|
||||
return "UU" + id.substr(2);
|
||||
}
|
||||
|
||||
bool video_is_known(sqlite3 *db, const std::string &channel_id, const std::string &video_id)
|
||||
{
|
||||
sqlite3_stmt *query;
|
||||
SC(sqlite3_prepare_v2(db, "SELECT 1 FROM videos WHERE channelId=?1 AND videoId=?2 LIMIT 1;", -1, &query, nullptr));
|
||||
SC(sqlite3_bind_text(query, 1, channel_id.c_str(), -1, SQLITE_TRANSIENT));
|
||||
SC(sqlite3_bind_text(query, 2, video_id.c_str(), -1, SQLITE_TRANSIENT));
|
||||
|
||||
bool known = false;
|
||||
const int res = sqlite3_step(query);
|
||||
if(res == SQLITE_ROW) {
|
||||
known = sqlite3_column_int(query, 0) > 0;
|
||||
} else if(res == SQLITE_DONE) {
|
||||
known = false;
|
||||
}
|
||||
SC(sqlite3_finalize(query));
|
||||
|
||||
return known;
|
||||
}
|
||||
|
||||
void add_video(sqlite3 *db, const json &snippet, const std::string &channel_id) {
|
||||
const std::string video_id = snippet["resourceId"]["videoId"];
|
||||
const std::string title = snippet["title"];
|
||||
const std::string description = snippet["description"];
|
||||
const int flags = 0;
|
||||
const std::string published = snippet["publishedAt"];
|
||||
|
||||
sqlite3_stmt *query;
|
||||
SC(sqlite3_prepare_v2(db, "INSERT INTO videos (videoId, channelId, title, description, flags, published) values(?1,?2,?3,?4,?5,?6);", -1, &query, nullptr));
|
||||
SC(sqlite3_bind_text(query, 1, video_id.c_str(), -1, SQLITE_TRANSIENT));
|
||||
SC(sqlite3_bind_text(query, 2, channel_id.c_str(), -1, SQLITE_TRANSIENT));
|
||||
SC(sqlite3_bind_text(query, 3, title.c_str(), -1, SQLITE_TRANSIENT));
|
||||
SC(sqlite3_bind_text(query, 4, description.c_str(), -1, SQLITE_TRANSIENT));
|
||||
SC(sqlite3_bind_int(query, 5, flags));
|
||||
SC(sqlite3_bind_text(query, 6, published.c_str(), -1, SQLITE_TRANSIENT));
|
||||
sqlite3_step(query);
|
||||
SC(sqlite3_finalize(query));
|
||||
}
|
||||
|
||||
|
||||
void Channel::fetch_new_videos(sqlite3 *db, progress_info *info, std::optional<std::string> after, std::optional<int> max_count)
|
||||
{
|
||||
const std::string playlist_id = upload_playlist();
|
||||
std::map<std::string, std::string> params = {
|
||||
{"part", "snippet"},
|
||||
{"playlistId", playlist_id},
|
||||
{"maxResults", "50"},
|
||||
{"key", yt_config.api_key},
|
||||
};
|
||||
|
||||
sqlite3_exec(db, "BEGIN TRANSACTION;", nullptr, nullptr, nullptr);
|
||||
|
||||
int processed = 0;
|
||||
bool abort = false;
|
||||
while(true) {
|
||||
const json response = api_request("https://content.googleapis.com/youtube/v3/playlistItems", params);
|
||||
|
||||
if(response.empty())
|
||||
break;
|
||||
|
||||
for(auto &item: response["items"]) {
|
||||
auto snippet = item["snippet"];
|
||||
std::string channel_id = snippet["channelId"];
|
||||
std::string video_id = snippet["resourceId"]["videoId"];
|
||||
std::string title = snippet["title"];
|
||||
|
||||
if(after) {
|
||||
auto publishedAt = snippet["publishedAt"];
|
||||
if(publishedAt < *after) {
|
||||
//fprintf(stderr, "Stopping at video '%s': Too old.\r\n", title.c_str());
|
||||
abort = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if(video_is_known(db, channel_id, video_id)) {
|
||||
//fprintf(stderr, "Stopping at video '%s': Already known.\r\n", title.c_str());
|
||||
abort = true;
|
||||
break;
|
||||
}
|
||||
|
||||
add_video(db, snippet, channel_id);
|
||||
//fprintf(stderr, "New video: '%s': %s.\r\n", title.c_str(), video_id.c_str());
|
||||
processed += 1;
|
||||
if(max_count && processed >= *max_count) {
|
||||
abort = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const json page_info = response["pageInfo"];
|
||||
const int results = page_info["totalResults"];
|
||||
if(info)
|
||||
update_progress(info, processed, results);
|
||||
|
||||
if(!abort && response.contains("nextPageToken")) {
|
||||
params["pageToken"] = response["nextPageToken"];
|
||||
//fprintf(stderr, "Processed %d. Next page...\r\n", processed);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
sqlite3_exec(db, "COMMIT TRANSACTION;", nullptr, nullptr, nullptr);
|
||||
}
|
||||
|
||||
void Channel::load_info(sqlite3 *db)
|
||||
{
|
||||
sqlite3_stmt *query;
|
||||
SC(sqlite3_prepare_v2(db, "SELECT "
|
||||
"(SELECT count(*) FROM videos WHERE channelId = ?1), "
|
||||
"(SELECT count(*) FROM videos WHERE channelId = ?1 AND flags & ?2 = 0)"
|
||||
";", -1, &query, nullptr));
|
||||
SC(sqlite3_bind_text(query, 1, id.c_str(), -1, SQLITE_TRANSIENT));
|
||||
SC(sqlite3_bind_int(query, 2, kWatched));
|
||||
sqlite3_step(query);
|
||||
video_count = sqlite3_column_int(query, 0);
|
||||
unwatched = sqlite3_column_int(query, 1);
|
||||
SC(sqlite3_finalize(query));
|
||||
}
|
||||
|
||||
void Channel::videos_watched(int count)
|
||||
{
|
||||
unwatched -= count;
|
||||
}
|
||||
|
||||
bool Channel::is_valid() const
|
||||
{
|
||||
return !id.empty() && !name.empty();
|
||||
}
|
||||
|
||||
Video::Video(sqlite3_stmt *row): id(get_string(row, 0)), title(get_string(row, 2)), description(get_string(row, 3)), flags(sqlite3_column_int(row, 4)), published(get_string(row, 5))
|
||||
{
|
||||
}
|
||||
|
||||
void Video::set_flag(sqlite3 *db, VideoFlag flag, bool value)
|
||||
{
|
||||
sqlite3_stmt *query;
|
||||
if(value){
|
||||
SC(sqlite3_prepare_v2(db, "UPDATE videos SET flags = flags | ?1 WHERE videoID = ?2;", -1, &query, nullptr));
|
||||
flags |= flag;
|
||||
} else {
|
||||
SC(sqlite3_prepare_v2(db, "UPDATE videos SET flags = flags & ~?1 WHERE videoID = ?2;", -1, &query, nullptr));
|
||||
flags &= ~flag;
|
||||
}
|
||||
SC(sqlite3_bind_int(query, 1, flag));
|
||||
SC(sqlite3_bind_text(query, 2, id.c_str(), -1, SQLITE_TRANSIENT));
|
||||
SC(sqlite3_step(query));
|
||||
SC(sqlite3_finalize(query));
|
||||
}
|
|
@ -0,0 +1,55 @@
|
|||
#pragma once
|
||||
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
class sqlite3;
|
||||
class sqlite3_stmt;
|
||||
class progress_info;
|
||||
|
||||
extern struct yt_config {
|
||||
std::string api_key;
|
||||
std::map<std::string, std::string> extra_headers;
|
||||
} yt_config;
|
||||
|
||||
class Channel
|
||||
{
|
||||
public:
|
||||
std::string id;
|
||||
std::string name;
|
||||
|
||||
Channel(sqlite3_stmt *row);
|
||||
static Channel add(sqlite3 *db, const std::string &selector, const std::string &value);
|
||||
|
||||
std::string upload_playlist() const;
|
||||
void fetch_new_videos(sqlite3 *db, progress_info *info=nullptr, std::optional<std::string> after={}, std::optional<int> max_count={});
|
||||
void load_info(sqlite3 *db);
|
||||
void videos_watched(int count);
|
||||
bool is_valid() const;
|
||||
|
||||
unsigned int video_count;
|
||||
unsigned int unwatched;
|
||||
private:
|
||||
Channel(const std::string &id, const std::string &name);
|
||||
};
|
||||
|
||||
enum VideoFlag {
|
||||
kNone = 0,
|
||||
kWatched = (1<<0),
|
||||
kDownloaded = (1<<1),
|
||||
};
|
||||
|
||||
struct Video
|
||||
{
|
||||
std::string id;
|
||||
std::string title;
|
||||
std::string description;
|
||||
int flags;
|
||||
std::string published;
|
||||
|
||||
Video(sqlite3_stmt *row);
|
||||
void set_flag(sqlite3 *db, VideoFlag flag, bool value=true);
|
||||
|
||||
size_t tui_title_width;
|
||||
};
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"apiKey": "YOUR_API_KEY",
|
||||
"extraHeaders": [
|
||||
{"key": "x-extra", "value": "heder"}
|
||||
]
|
||||
}
|
Loading…
Reference in New Issue