Mercurial > minori
changeset 317:b1f4d1867ab1
services: VERY initial Kitsu support
it only supports user authentication for now, but it's definitely
a start.
author | Paper <paper@paper.us.eu.org> |
---|---|
date | Wed, 12 Jun 2024 04:07:10 -0400 |
parents | 180714442770 |
children | 3b355fa948c7 |
files | Makefile.am include/core/anime.h include/core/anime_db.h include/core/config.h include/core/date.h include/core/http.h include/core/json.h include/gui/dialog/settings.h include/gui/widgets/drop_list_widget.h include/services/kitsu.h include/services/services.h src/core/anime.cc src/core/anime_db.cc src/core/config.cc src/core/date.cc src/core/http.cc src/gui/dialog/settings/application.cc src/gui/dialog/settings/library.cc src/gui/dialog/settings/services.cc src/gui/widgets/drop_list_widget.cc src/main.cc src/services/anilist.cc src/services/kitsu.cc src/services/services.cc src/track/media.cc |
diffstat | 25 files changed, 676 insertions(+), 98 deletions(-) [+] |
line wrap: on
line diff
--- a/Makefile.am Tue Jun 11 15:11:09 2024 -0400 +++ b/Makefile.am Wed Jun 12 04:07:10 2024 -0400 @@ -143,6 +143,7 @@ include/gui/widgets/anime_button.h \ include/gui/widgets/anime_info.h \ include/gui/widgets/clickable_label.h \ + include/gui/widgets/drop_list_widget.h \ include/gui/widgets/graph.h \ include/gui/widgets/optional_date.h \ include/gui/widgets/poster.h \ @@ -168,6 +169,7 @@ include/core/torrent.h \ include/library/library.h \ include/services/anilist.h \ + include/services/kitsu.h \ include/services/services.h \ include/sys/glib/dark_theme.h \ include/sys/osx/dark_theme.h \ @@ -220,6 +222,7 @@ src/gui/widgets/anime_button.cc \ src/gui/widgets/anime_info.cc \ src/gui/widgets/clickable_label.cc \ + src/gui/widgets/drop_list_widget.cc \ src/gui/widgets/elided_label.cc \ src/gui/widgets/optional_date.cc \ src/gui/widgets/poster.cc \ @@ -230,6 +233,7 @@ src/gui/window.cc \ src/library/library.cc \ src/services/anilist.cc \ + src/services/kitsu.cc \ src/services/services.cc \ src/track/media.cc \ src/main.cc \
--- a/include/core/anime.h Tue Jun 11 15:11:09 2024 -0400 +++ b/include/core/anime.h Wed Jun 12 04:07:10 2024 -0400 @@ -111,7 +111,7 @@ ScoreFormat::Point10, ScoreFormat::Point5, ScoreFormat::Point3}; struct ListInformation { - int id = 0; + std::string id; int progress = 0; int score = 0; // this will ALWAYS be in POINT_100 format internally ListStatus status = ListStatus::NotInList; @@ -135,7 +135,7 @@ std::vector<std::string> genres; std::vector<std::string> producers; SeriesFormat format = SeriesFormat::Unknown; - int audience_score = 0; + double audience_score = 0; std::string synopsis; int duration = 0; std::string poster_url; @@ -144,6 +144,7 @@ class Anime { public: /* User list data */ + std::string GetUserId() const; ListStatus GetUserStatus() const; int GetUserProgress() const; int GetUserScore() const; @@ -156,6 +157,7 @@ uint64_t GetUserTimeUpdated() const; std::string GetUserNotes() const; + void SetUserId(const std::string& id); void SetUserStatus(ListStatus status); void SetUserScore(int score); void SetUserProgress(int progress); @@ -179,7 +181,7 @@ std::vector<std::string> GetProducers() const; SeriesFormat GetFormat() const; SeriesSeason GetSeason() const; - int GetAudienceScore() const; + double GetAudienceScore() const; std::string GetSynopsis() const; int GetDuration() const; std::string GetPosterUrl() const; @@ -196,7 +198,7 @@ void SetGenres(std::vector<std::string> const& genres); void SetProducers(std::vector<std::string> const& producers); void SetFormat(SeriesFormat format); - void SetAudienceScore(int audience_score); + void SetAudienceScore(double audience_score); void SetSynopsis(std::string synopsis); void SetDuration(int duration); void SetPosterUrl(std::string poster);
--- a/include/core/anime_db.h Tue Jun 11 15:11:09 2024 -0400 +++ b/include/core/anime_db.h Wed Jun 12 04:07:10 2024 -0400 @@ -25,6 +25,12 @@ bool ParseDatabaseJSON(const nlohmann::json& json); bool LoadDatabaseFromDisk(); + + /* These are here to make sure that our service IDs don't collide + * and make the whole thing go boom. */ + int GetUnusedId(); + int LookupServiceId(Service service, const std::string& id_to_find); + int LookupServiceIdOrUnused(Service service, const std::string& id_to_find); }; extern Database db;
--- a/include/core/config.h Tue Jun 11 15:11:09 2024 -0400 +++ b/include/core/config.h Wed Jun 12 04:07:10 2024 -0400 @@ -2,6 +2,7 @@ #define MINORI_CORE_CONFIG_H_ #include "core/anime.h" +#include "core/time.h" #include "gui/locale.h" #include "gui/theme.h" @@ -40,6 +41,12 @@ std::string auth_token; int user_id; } anilist; + struct { + std::string access_token; + Time::Timestamp access_token_expiration; /* Unix time */ + std::string refresh_token; + std::string user_id; + } kitsu; } auth; struct {
--- a/include/core/date.h Tue Jun 11 15:11:09 2024 -0400 +++ b/include/core/date.h Wed Jun 12 04:07:10 2024 -0400 @@ -4,9 +4,14 @@ #include "json/json_fwd.hpp" #include <optional> +#include <string> class QDate; +/* TODO: refactor constructors, as they aren't meant + * to be used in this way and may cause problems down + * the line */ + class Date { public: using Year = unsigned int; @@ -29,6 +34,7 @@ Date(); Date(Year y); Date(Year y, Month m, Day d); + Date(const std::string& str); Date(const QDate& date); Date(const nlohmann::json& json); bool IsValid() const;
--- a/include/core/http.h Tue Jun 11 15:11:09 2024 -0400 +++ b/include/core/http.h Wed Jun 12 04:07:10 2024 -0400 @@ -10,6 +10,12 @@ namespace HTTP { +/* calls libcurl to encode/decode */ +std::string UrlEncode(const std::string& data); +std::string UrlDecode(const std::string& data); + +std::string EncodeParamsList(std::string base, const std::map<std::string, std::string>& params); + enum class Type { Get, Post
--- a/include/core/json.h Tue Jun 11 15:11:09 2024 -0400 +++ b/include/core/json.h Wed Jun 12 04:07:10 2024 -0400 @@ -24,6 +24,7 @@ namespace JSON { +/* TODO: refactor these to return a std::optional... */ template<typename T = std::string> T GetString(const nlohmann::json& json, const nlohmann::json::json_pointer& ptr, T def) { if (json.contains(ptr) && json[ptr].is_string())
--- a/include/gui/dialog/settings.h Tue Jun 11 15:11:09 2024 -0400 +++ b/include/gui/dialog/settings.h Wed Jun 12 04:07:10 2024 -0400 @@ -15,22 +15,6 @@ #include <QVBoxLayout> #include <QWidget> -/* !!! MOVE THIS ELSEWHERE! */ -class DroppableListWidget : public QListWidget { - Q_OBJECT - -public: - explicit DroppableListWidget(QWidget* parent); - -signals: - void FilesDropped(QStringList list); - -protected: - void dragEnterEvent(QDragEnterEvent* event) override; - void dragMoveEvent(QDragMoveEvent* event) override; - void dropEvent(QDropEvent* event) override; -}; - class SettingsPage : public QWidget { Q_OBJECT @@ -56,6 +40,7 @@ private: QWidget* CreateMainPage(); QWidget* CreateAniListPage(); + QWidget* CreateKitsuPage(); decltype(session.config.service) service; };
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/include/gui/widgets/drop_list_widget.h Wed Jun 12 04:07:10 2024 -0400 @@ -0,0 +1,27 @@ +#ifndef MINORI_GUI_WIDGETS_DROP_LIST_WIDGET_H_ +#define MINORI_GUI_WIDGETS_DROP_LIST_WIDGET_H_ + +#include <QListWidget> +#include <QString> +#include <QWidget> + +class QDragEnterEvent; +class QDragMoveEvent; +class QDropEvent; + +class DroppableListWidget : public QListWidget { + Q_OBJECT + +public: + explicit DroppableListWidget(QWidget* parent); + +signals: + void FilesDropped(QStringList list); + +protected: + void dragEnterEvent(QDragEnterEvent* event) override; + void dragMoveEvent(QDragMoveEvent* event) override; + void dropEvent(QDropEvent* event) override; +}; + +#endif // MINORI_GUI_WIDGETS_DROP_LIST_WIDGET_H_ \ No newline at end of file
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/include/services/kitsu.h Wed Jun 12 04:07:10 2024 -0400 @@ -0,0 +1,24 @@ +#ifndef MINORI_SERVICES_KITSU_H_ +#define MINORI_SERVICES_KITSU_H_ + +#include "core/anime.h" +#include "core/date.h" + +#include <string> +#include <vector> + +namespace Services { +namespace Kitsu { + +/* neither of these are stored in the config and only held temporarily */ +bool AuthorizeUser(const std::string& email, const std::string& password); + +int GetAnimeList(); +std::vector<int> Search(const std::string& search); +std::vector<int> GetSeason(Anime::SeriesSeason season, Date::Year year); +int UpdateAnimeEntry(int id); + +} // namespace Kitsu +} // namespace Services + +#endif // MINORI_SERVICES_KITSU_H_
--- a/include/services/services.h Tue Jun 11 15:11:09 2024 -0400 +++ b/include/services/services.h Wed Jun 12 04:07:10 2024 -0400 @@ -13,7 +13,6 @@ std::vector<int> Search(const std::string& search); std::vector<int> GetSeason(Anime::SeriesSeason season, Date::Year year); void UpdateAnimeEntry(int id); -bool Authorize(); }; // namespace Services
--- a/src/core/anime.cc Tue Jun 11 15:11:09 2024 -0400 +++ b/src/core/anime.cc Wed Jun 12 04:07:10 2024 -0400 @@ -33,6 +33,11 @@ list_info_.reset(); } +std::string Anime::GetUserId() const { + assert(list_info_.has_value()); + return list_info_->id; +} + ListStatus Anime::GetUserStatus() const { assert(list_info_.has_value()); return list_info_->status; @@ -117,6 +122,11 @@ return list_info_->notes; } +void Anime::SetUserId(const std::string& id) { + assert(list_info_.has_value()); + list_info_->id = id; +} + void Anime::SetUserStatus(ListStatus status) { assert(list_info_.has_value()); list_info_->status = status; @@ -235,7 +245,7 @@ return (month.has_value() ? GetSeasonForMonth(month.value()) : SeriesSeason::Unknown); } -int Anime::GetAudienceScore() const { +double Anime::GetAudienceScore() const { return info_.audience_score; } @@ -311,7 +321,7 @@ info_.format = format; } -void Anime::SetAudienceScore(int audience_score) { +void Anime::SetAudienceScore(double audience_score) { info_.audience_score = audience_score; }
--- a/src/core/anime_db.cc Tue Jun 11 15:11:09 2024 -0400 +++ b/src/core/anime_db.cc Wed Jun 12 04:07:10 2024 -0400 @@ -12,7 +12,9 @@ #include <fstream> #include <exception> +#include <cstdlib> #include <iostream> +#include <random> namespace Anime { @@ -300,6 +302,42 @@ return true; } +int Database::GetUnusedId() { + /* TODO: move these out of here */ + + std::random_device rd; + std::mt19937 gen(rd()); + std::uniform_int_distribution<int> distrib(1, INT_MAX); + int res; + + do { + res = distrib(gen); + } while (items.count(res)); + + return res; +} + +int Database::LookupServiceId(Service service, const std::string& id_to_find) { + for (const auto& [id, anime] : items) { + std::optional<std::string> service_id = anime.GetServiceId(service); + if (!service_id) + continue; + + if (service_id == id_to_find) + return id; + } + + return 0; +} + +int Database::LookupServiceIdOrUnused(Service service, const std::string& id_to_find) { + int id = LookupServiceId(service, id_to_find); + if (id) + return id; + + return GetUnusedId(); +} + Database db; } // namespace Anime
--- a/src/core/config.cc Tue Jun 11 15:11:09 2024 -0400 +++ b/src/core/config.cc Wed Jun 12 04:07:10 2024 -0400 @@ -60,6 +60,11 @@ auth.anilist.auth_token = INI::GetIniString(ini, "Authentication/AniList", "Auth Token", ""); auth.anilist.user_id = INI::GetIniInteger<int>(ini, "Authentication/AniList", "User ID", 0); + auth.kitsu.access_token = INI::GetIniString(ini, "Authentication/Kitsu", "Access Token", ""); + auth.kitsu.access_token_expiration = INI::GetIniInteger<Time::Timestamp>(ini, "Authentication/Kitsu", "Access Token Expiration", 0); + auth.kitsu.refresh_token = INI::GetIniString(ini, "Authentication/Kitsu", "Refresh Token", ""); + auth.kitsu.user_id = INI::GetIniString(ini, "Authentication/Kitsu", "User ID", ""); + torrents.feed_link = INI::GetIniString(ini, "Torrents", "RSS feed", "https://www.tokyotosho.info/rss.php?filter=1,11&zwnj=0"); @@ -135,6 +140,11 @@ ini["Authentication/AniList"]["Auth Token"] = auth.anilist.auth_token; ini["Authentication/AniList"]["User ID"] = Strings::ToUtf8String(auth.anilist.user_id); + ini["Authentication/Kitsu"]["Access Token"] = auth.kitsu.access_token; + ini["Authentication/Kitsu"]["Access Token Expiration"] = Strings::ToUtf8String(auth.kitsu.access_token_expiration); + ini["Authentication/Kitsu"]["Refresh Token"] = auth.kitsu.refresh_token; + ini["Authentication/Kitsu"]["User ID"] = auth.kitsu.user_id; + ini["Appearance"]["Theme"] = Translate::ToString(theme.GetTheme()); ini["Torrents"]["RSS feed"] = torrents.feed_link;
--- a/src/core/date.cc Tue Jun 11 15:11:09 2024 -0400 +++ b/src/core/date.cc Wed Jun 12 04:07:10 2024 -0400 @@ -5,6 +5,7 @@ #include <QDebug> #include <algorithm> +#include <cstdio> /* An implementation of AniList's "fuzzy date" */ @@ -21,6 +22,22 @@ SetDay(d); } +Date::Date(const std::string& str) { + unsigned int y, m, d; + + /* I don't like this that much, but it works... */ + int amt = std::sscanf(str.c_str(), "%4u-%2u-%2u", &y, &m, &d); + + if (amt > 0) + SetYear(y); + + if (amt > 1) + SetMonth(static_cast<Date::Month>(m - 1)); + + if (amt > 2) + SetDay(d); +} + Date::Date(const QDate& date) { SetYear(date.year()); auto m = date.month();
--- a/src/core/http.cc Tue Jun 11 15:11:09 2024 -0400 +++ b/src/core/http.cc Wed Jun 12 04:07:10 2024 -0400 @@ -8,6 +8,60 @@ namespace HTTP { +std::string UrlEncode(const std::string& data) { + /* why do I need to init curl just for this? wtf? */ + CURL *curl = curl_easy_init(); + if (!curl) + return ""; /* no way! */ + + char* output = curl_easy_escape(curl, data.data(), data.size()); + if (!output) { + curl_easy_cleanup(curl); + return ""; + } + + std::string str(output); + + curl_free(output); + curl_easy_cleanup(curl); + + return str; +} + +std::string UrlDecode(const std::string& data) { + CURL *curl = curl_easy_init(); + if (!curl) + return ""; + + int outlength; + char* output = curl_easy_unescape(curl, data.data(), data.size(), &outlength); + if (!output) { + curl_easy_cleanup(curl); + return ""; + } + + std::string str(output, outlength); + + curl_free(output); + curl_easy_cleanup(curl); + + return str; +} + +std::string EncodeParamsList(std::string base, const std::map<std::string, std::string>& params) { + std::size_t count = 0; + for (const auto& param : params) { + base += (!count ? "?" : "&"); + base += UrlEncode(param.first); + base += "="; + base += UrlEncode(param.second); + + count++; + } + + return base; +} + static size_t WriteCallback(void* contents, size_t size, size_t nmemb, void* userdata) { reinterpret_cast<QByteArray*>(userdata)->append(reinterpret_cast<char*>(contents), size * nmemb); return size * nmemb; @@ -35,7 +89,7 @@ session.IncrementRequests(); curl_easy_cleanup(curl); if (res != CURLE_OK) - std::cerr << "curl_easy_perform(curl) failed!: " << curl_easy_strerror(res) << std::endl; + session.SetStatusBar(std::string("curl_easy_perform(curl) failed!: ") + curl_easy_strerror(res)); } return userdata; } @@ -122,7 +176,7 @@ callback_data_mutex_.lock(); if (res != CURLE_OK && !(res == CURLE_WRITE_ERROR && cancelled_)) - std::cerr << "curl_easy_perform(curl) failed!: " << curl_easy_strerror(res) << std::endl; + session.SetStatusBar(std::string("curl_easy_perform(curl) failed!: ") + curl_easy_strerror(res)); callback_data_mutex_.unlock(); }
--- a/src/gui/dialog/settings/application.cc Tue Jun 11 15:11:09 2024 -0400 +++ b/src/gui/dialog/settings/application.cc Wed Jun 12 04:07:10 2024 -0400 @@ -111,7 +111,7 @@ } { - /* Hopefully I made this easy to parse... */ + /* Hopefully I made this easy to read... */ QCheckBox* hl_above_anime_box = new QCheckBox(tr("Display highlighted anime above others"), appearance_group_box); hl_above_anime_box->setCheckState(highlighted_anime_above_others ? Qt::Checked : Qt::Unchecked);
--- a/src/gui/dialog/settings/library.cc Tue Jun 11 15:11:09 2024 -0400 +++ b/src/gui/dialog/settings/library.cc Wed Jun 12 04:07:10 2024 -0400 @@ -1,63 +1,19 @@ #include "core/session.h" #include "core/strings.h" #include "gui/dialog/settings.h" +#include "gui/widgets/drop_list_widget.h" #include <QCheckBox> #include <QDir> -#include <QDropEvent> #include <QFileDialog> -#include <QFileInfo> #include <QGroupBox> #include <QLabel> #include <QListWidget> #include <QListWidgetItem> -#include <QMimeData> #include <QPushButton> #include <QSizePolicy> #include <QVBoxLayout> -#include <algorithm> -#include <iostream> - -DroppableListWidget::DroppableListWidget(QWidget* parent) : QListWidget(parent) { - setAcceptDrops(true); -} - -void DroppableListWidget::dragMoveEvent(QDragMoveEvent* event) { - if (event->mimeData()->hasUrls()) - event->acceptProposedAction(); -} - -void DroppableListWidget::dragEnterEvent(QDragEnterEvent* event) { - if (event->mimeData()->hasUrls()) - event->acceptProposedAction(); -} - -void DroppableListWidget::dropEvent(QDropEvent* event) { - const QMimeData* mime_data = event->mimeData(); - - if (!mime_data->hasUrls()) - return; - - QStringList path_list; - QList<QUrl> url_list = mime_data->urls(); - - for (const auto& url : url_list) { - if (!url.isLocalFile()) - continue; - - const QString file = url.toLocalFile(); - const QFileInfo fileinfo(file); - if (fileinfo.exists() && fileinfo.isDir()) - path_list.append(file); - } - - if (!path_list.isEmpty()) - emit FilesDropped(path_list); - - event->acceptProposedAction(); -} - QWidget* SettingsPageLibrary::CreateFoldersWidget() { QWidget* result = new QWidget(this); result->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Maximum);
--- a/src/gui/dialog/settings/services.cc Tue Jun 11 15:11:09 2024 -0400 +++ b/src/gui/dialog/settings/services.cc Wed Jun 12 04:07:10 2024 -0400 @@ -4,6 +4,7 @@ #include "gui/dialog/settings.h" #include "gui/translate/anime.h" #include "services/anilist.h" +#include "services/kitsu.h" #include <QComboBox> #include <QGroupBox> #include <QLabel> @@ -59,6 +60,71 @@ return result; } +QWidget* SettingsPageServices::CreateKitsuPage() { + QWidget* result = new QWidget(this); + result->setSizePolicy(QSizePolicy::Maximum, QSizePolicy::Maximum); + + QVBoxLayout* full_layout = new QVBoxLayout(result); + + { + /* Account */ + QGroupBox* group_box = new QGroupBox(tr("Account"), result); + group_box->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Maximum); + + QVBoxLayout* group_box_layout = new QVBoxLayout(group_box); + + { + QWidget* credentials_grid = new QWidget(group_box); + QGridLayout* credentials_grid_layout = new QGridLayout(credentials_grid); + + /* E-mail */ + QLabel* email_label = new QLabel(tr("&E-mail"), credentials_grid); + QLineEdit* email = new QLineEdit(credentials_grid); + email_label->setBuddy(email); + credentials_grid_layout->addWidget(email_label, 0, 0); + credentials_grid_layout->addWidget(email, 1, 0); + + QLabel* password_label = new QLabel(tr("&Password:"), credentials_grid); + QLineEdit* password = new QLineEdit(credentials_grid); + password->setEchoMode(QLineEdit::Password); + password_label->setBuddy(password); + credentials_grid_layout->addWidget(password_label, 0, 1); + credentials_grid_layout->addWidget(password, 1, 1); + + { + QPushButton* auth_button = new QPushButton(credentials_grid); + connect(auth_button, &QPushButton::clicked, this, [email, password] { + Services::Kitsu::AuthorizeUser(Strings::ToUtf8String(email->text()), Strings::ToUtf8String(password->text())); + }); + auth_button->setText(session.config.auth.kitsu.access_token.empty() ? tr("Authorize...") + : tr("Re-authorize...")); + credentials_grid_layout->addWidget(auth_button, 1, 2); + } + + credentials_grid_layout->setContentsMargins(0, 0, 0, 0); + + group_box_layout->addWidget(credentials_grid); + } + + { + /* Note on password storing */ + QLabel* note_label = new QLabel(tr("Your e-mail and password are never stored by Minori and will only be used to authorize with Kitsu.\nFor more information, see <a href=\"https://kitsu.docs.apiary.io/#introduction/authentication\">Kitsu's API documentation</a>"), group_box); + note_label->setTextFormat(Qt::RichText); + note_label->setWordWrap(true); + note_label->setTextInteractionFlags(Qt::TextBrowserInteraction); + note_label->setOpenExternalLinks(true); + group_box_layout->addWidget(note_label); + } + + full_layout->addWidget(group_box); + } + + full_layout->setSpacing(10); + full_layout->addStretch(); + + return result; +} + QWidget* SettingsPageServices::CreateAniListPage() { QWidget* result = new QWidget(this); result->setSizePolicy(QSizePolicy::Maximum, QSizePolicy::Maximum); @@ -93,6 +159,7 @@ QLabel* note_label = new QLabel(tr("<a href=\"http://anilist.co/\">Create a new AniList account</a>"), group_box); note_label->setTextFormat(Qt::RichText); + note_label->setWordWrap(true); note_label->setTextInteractionFlags(Qt::TextBrowserInteraction); note_label->setOpenExternalLinks(true); layout->addWidget(note_label); @@ -114,5 +181,6 @@ SettingsPageServices::SettingsPageServices(QWidget* parent) : SettingsPage(parent, tr("Services")) { service = session.config.service; AddTab(CreateMainPage(), tr("Main")); + AddTab(CreateKitsuPage(), tr("Kitsu")); AddTab(CreateAniListPage(), tr("AniList")); }
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/gui/widgets/drop_list_widget.cc Wed Jun 12 04:07:10 2024 -0400 @@ -0,0 +1,47 @@ +#include "gui/widgets/drop_list_widget.h" + +#include <QDragMoveEvent> +#include <QDropEvent> +#include <QMimeData> +#include <QFileInfo> + +/* currently this only sends local paths that are folders */ + +DroppableListWidget::DroppableListWidget(QWidget* parent) : QListWidget(parent) { + setAcceptDrops(true); +} + +void DroppableListWidget::dragMoveEvent(QDragMoveEvent* event) { + if (event->mimeData()->hasUrls()) + event->acceptProposedAction(); +} + +void DroppableListWidget::dragEnterEvent(QDragEnterEvent* event) { + if (event->mimeData()->hasUrls()) + event->acceptProposedAction(); +} + +void DroppableListWidget::dropEvent(QDropEvent* event) { + const QMimeData* mime_data = event->mimeData(); + + if (!mime_data->hasUrls()) + return; + + QStringList path_list; + QList<QUrl> url_list = mime_data->urls(); + + for (const auto& url : url_list) { + if (!url.isLocalFile()) + continue; + + const QString file = url.toLocalFile(); + const QFileInfo fileinfo(file); + if (fileinfo.exists() && fileinfo.isDir()) + path_list.append(file); + } + + if (!path_list.isEmpty()) + emit FilesDropped(path_list); + + event->acceptProposedAction(); +} \ No newline at end of file
--- a/src/main.cc Tue Jun 11 15:11:09 2024 -0400 +++ b/src/main.cc Wed Jun 12 04:07:10 2024 -0400 @@ -9,7 +9,7 @@ #include <QStyleFactory> #include <QTranslator> -#include <iostream> +#include <cstdlib> int main(int argc, char** argv) { QApplication app(argc, argv);
--- a/src/services/anilist.cc Tue Jun 11 15:11:09 2024 -0400 +++ b/src/services/anilist.cc Wed Jun 12 04:07:10 2024 -0400 @@ -23,6 +23,9 @@ #include <iostream> +/* This file really sucks because it was made when I was first + * really "learning" C++ */ + using namespace nlohmann::literals::json_literals; namespace Services { @@ -54,19 +57,17 @@ "synonyms\n" \ "description(asHtml: false)\n" -class Account { -public: +/* FIXME: why is this here */ + +static struct { int UserId() const { return session.config.auth.anilist.user_id; } void SetUserId(const int id) { session.config.auth.anilist.user_id = id; } std::string AuthToken() const { return session.config.auth.anilist.auth_token; } void SetAuthToken(const std::string& auth_token) { session.config.auth.anilist.auth_token = auth_token; } - bool Authenticated() const { return !AuthToken().empty(); } - bool IsValid() const { return UserId() && Authenticated(); } -}; - -static Account account; + bool IsValid() const { return UserId() && !AuthToken().empty(); } +} account; static std::string SendRequest(const std::string& data) { std::vector<std::string> headers = {"Authorization: Bearer " + account.AuthToken(), "Accept: application/json", @@ -89,7 +90,7 @@ if (out.contains("/errors"_json_pointer) && out.at("/errors"_json_pointer).is_array()) { for (const auto& error : out.at("/errors"_json_pointer)) - std::cerr << "[AniList] Received an error in response: " + std::cerr << "AniList: Received an error in response: " << JSON::GetString<std::string>(error, "/message"_json_pointer, "") << std::endl; session.SetStatusBar("AniList: Received an error in response!"); @@ -149,14 +150,19 @@ } static int ParseMediaJson(const nlohmann::json& json) { - int id = JSON::GetNumber(json, "/id"_json_pointer); - if (!id) + if (!json.contains("/id"_json_pointer) || !json["/id"_json_pointer].is_number()) return 0; + std::string service_id = Strings::ToUtf8String(json["/id"_json_pointer].get<int>()); + + int id = Anime::db.LookupServiceIdOrUnused(Anime::Service::AniList, service_id); + Anime::Anime& anime = Anime::db.items[id]; anime.SetId(id); - anime.SetServiceId(Anime::Service::AniList, Strings::ToUtf8String(id)); - anime.SetServiceId(Anime::Service::MyAnimeList, Strings::ToUtf8String(JSON::GetNumber(json, "/id_mal"_json_pointer))); + anime.SetServiceId(Anime::Service::AniList, service_id); + + if (json.contains("/id_mal"_json_pointer)) + anime.SetServiceId(Anime::Service::MyAnimeList, json["/id_mal"_json_pointer].get<std::string>()); ParseTitle(json.at("/title"_json_pointer), anime); @@ -185,7 +191,9 @@ } static int ParseListItem(const nlohmann::json& json) { - int id = ParseMediaJson(json["media"]); + int id = ParseMediaJson(json); + if (!id) + return 0; Anime::Anime& anime = Anime::db.items[id]; @@ -205,15 +213,15 @@ } static int ParseList(const nlohmann::json& json) { - for (const auto& entry : json["entries"].items()) { + for (const auto& entry : json["entries"].items()) ParseListItem(entry.value()); - } + return 1; } int GetAnimeList() { if (!account.IsValid()) { - session.SetStatusBar("AniList: Account isn't valid!"); + session.SetStatusBar("AniList: Account isn't valid! (unauthorized?)"); return 0; }
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/services/kitsu.cc Wed Jun 12 04:07:10 2024 -0400 @@ -0,0 +1,308 @@ +#include "services/anilist.h" +#include "core/anime.h" +#include "core/anime_db.h" +#include "core/date.h" +#include "core/config.h" +#include "core/http.h" +#include "core/json.h" +#include "core/session.h" +#include "core/strings.h" +#include "core/time.h" +#include "gui/translate/anilist.h" + +#include <QByteArray> +#include <QDate> +#include <QDesktopServices> +#include <QInputDialog> +#include <QLineEdit> +#include <QMessageBox> +#include <QUrl> + +#include <chrono> +#include <exception> +#include <string_view> + +#include <iostream> + +using namespace nlohmann::literals::json_literals; + +static constexpr std::string_view CLIENT_ID = "dd031b32d2f56c990b1425efe6c42ad847e7fe3ab46bf1299f05ecd856bdb7dd"; +static constexpr std::string_view CLIENT_SECRET = "54d7307928f63414defd96399fc31ba847961ceaecef3a5fd93144e960c0e151"; + +static constexpr std::string_view BASE_API_PATH = "https://kitsu.io/api/edge"; +static constexpr std::string_view OAUTH_PATH = "https://kitsu.io/api/oauth/token"; + +namespace Services { +namespace Kitsu { + +/* This nifty little function basically handles authentication AND reauthentication. */ +static bool SendAuthRequest(const nlohmann::json& data) { + static const std::vector<std::string> headers = { + {"Content-Type: application/json"} + }; + + const std::string ret = Strings::ToUtf8String(HTTP::Request(std::string(OAUTH_PATH), headers, data.dump(), HTTP::Type::Post)); + if (ret.empty()) { + session.SetStatusBar("Kitsu: Request returned empty data!"); + return false; + } + + nlohmann::json result; + try { + result = nlohmann::json::parse(ret, nullptr, false); + } catch (const std::exception& ex) { + session.SetStatusBar(std::string("Kitsu: Failed to parse authorization data with error \"") + ex.what() + "\"!"); + return false; + } + + if (result.contains("/error"_json_pointer)) { + std::string status = "Kitsu: Failed with error \""; + status += result["/error"_json_pointer].get<std::string>(); + + if (result.contains("/error_description"_json_pointer)) { + status += "\" and description \""; + status += result["/error_description"_json_pointer].get<std::string>(); + } + + status += "\"!"; + + session.SetStatusBar(status); + return false; + } + + const std::vector<nlohmann::json::json_pointer> required = { + "/access_token"_json_pointer, + "/created_at"_json_pointer, + "/expires_in"_json_pointer, + "/refresh_token"_json_pointer, + "/scope"_json_pointer, + "/token_type"_json_pointer + }; + + for (const auto& ptr : required) { + if (!result.contains(ptr)) { + session.SetStatusBar("Kitsu: Authorization request returned bad data!"); + return false; + } + } + + session.config.auth.kitsu.access_token = result["/access_token"_json_pointer].get<std::string>(); + session.config.auth.kitsu.access_token_expiration + = result["/created_at"_json_pointer].get<Time::Timestamp>(); + + result["/expires_in"_json_pointer].get<Time::Timestamp>(); + session.config.auth.kitsu.refresh_token = result["/refresh_token"_json_pointer].get<std::string>(); + + /* the next two are not that important */ + + return true; +} + +static bool RefreshAccessToken(std::string& access_token, const std::string& refresh_token) { + const nlohmann::json request = { + {"grant_type", "refresh_token"}, + {"refresh_token", refresh_token} + }; + + if (!SendAuthRequest(request)) + return false; + + return true; +} + +/* ----------------------------------------------------------------------------- */ + +static std::optional<std::string> AccountAccessToken() { + auto& auth = session.config.auth.kitsu; + + if (Time::GetSystemTime() >= session.config.auth.kitsu.access_token_expiration) + if (!RefreshAccessToken(auth.access_token, auth.refresh_token)) + return std::nullopt; + + return auth.access_token; +} + +/* ----------------------------------------------------------------------------- */ + +static std::optional<std::string> SendRequest(const std::string& path, const std::map<std::string, std::string>& params) { + std::optional<std::string> token = AccountAccessToken(); + if (!token) + return std::nullopt; + + const std::vector<std::string> headers = { + "Accept: application/vnd.api+json", + "Authorization: Bearer " + token.value(), + "Content-Type: application/vnd.api+json" + }; + + const std::string url = HTTP::EncodeParamsList(std::string(BASE_API_PATH) + path, params); + + return Strings::ToUtf8String(HTTP::Request(url, headers, "", HTTP::Type::Get)); +} + +static void ParseTitleJson(Anime::Anime& anime, const nlohmann::json& json) { + static const std::map<std::string, Anime::TitleLanguage> lookup = { + {"en", Anime::TitleLanguage::English}, + {"en_jp", Anime::TitleLanguage::Romaji}, + {"ja_jp", Anime::TitleLanguage::Native} + }; + + for (const auto& [string, title] : lookup) + if (json.contains(string)) + anime.SetTitle(title, json[string].get<std::string>()); +} + +static void ParseSubtype(Anime::Anime& anime, const std::string& str) { + static const std::map<std::string, Anime::SeriesFormat> lookup = { + {"ONA", Anime::SeriesFormat::Ona}, + {"OVA", Anime::SeriesFormat::Ova}, + {"TV", Anime::SeriesFormat::Tv}, + {"movie", Anime::SeriesFormat::Movie}, + {"music", Anime::SeriesFormat::Music}, + {"special", Anime::SeriesFormat::Special} + }; + + if (lookup.find(str) == lookup.end()) + return; + + anime.SetFormat(lookup.at(str)); +} + +static const std::string FAILED_TO_PARSE = "Kitsu: Failed to parse anime object!"; + +static int ParseAnimeJson(const nlohmann::json& json) { + const std::string service_id = json["/id"_json_pointer].get<std::string>(); + if (service_id.empty()) { + session.SetStatusBar(FAILED_TO_PARSE); + return 0; + } + + if (!json.contains("/attributes"_json_pointer)) { + session.SetStatusBar(FAILED_TO_PARSE); + return 0; + } + + const auto& attributes = json["/attributes"_json_pointer]; + + int id = Anime::db.LookupServiceIdOrUnused(Anime::Service::Kitsu, service_id); + if (!id) { + session.SetStatusBar(FAILED_TO_PARSE); + return 0; + } + + Anime::Anime& anime = Anime::db.items[id]; + + anime.SetId(id); + anime.SetServiceId(Anime::Service::Kitsu, service_id); + anime.SetSynopsis(attributes["/synopsis"_json_pointer].get<std::string>()); + ParseTitleJson(anime, attributes["/titles"_json_pointer]); + + // FIXME: parse abbreviatedTitles for synonyms?? + + anime.SetAudienceScore(JSON::GetNumber<double>(attributes, "/averageRating"_json_pointer)); + + if (attributes.contains("/startDate"_json_pointer)) + anime.SetAirDate(attributes["/startDate"_json_pointer].get<std::string>()); + + // TODO: endDate + + if (attributes.contains("/subtype"_json_pointer)) + ParseSubtype(anime, attributes["/subtype"_json_pointer].get<std::string>()); + + anime.SetPosterUrl(attributes["/posterImage/original"_json_pointer].get<std::string>()); + anime.SetEpisodes(attributes["/episodeCount"_json_pointer].get<int>()); + anime.SetDuration(attributes["/episodeLength"_json_pointer].get<int>()); + + return id; +} + +static int ParseLibraryJson(const nlohmann::json& json) { + if (!json.contains("/relationships/anime/data"_json_pointer) + || !json.contains("/attributes"_json_pointer) + || !json.contains("/id"_json_pointer)) { + session.SetStatusBar("Kitsu: Failed to parse library object!"); + return 0; + } + + int id = ParseAnimeJson(json["/relationships/anime/data"_json_pointer]); + if (!id) + return 0; + + const auto& attributes = json["/attributes"_json_pointer]; + + const std::string library_id = json["/id"_json_pointer].get<std::string>(); + + Anime::Anime& anime = Anime::db.items[id]; + + anime.AddToUserList(); + + anime.SetUserId(library_id); + anime.SetUserDateStarted(Date(attributes["/startedAt"_json_pointer].get<std::string>())); + anime.SetUserDateCompleted(Date(attributes["/finishedAt"_json_pointer].get<std::string>())); + anime.SetUserNotes(attributes["/notes"_json_pointer].get<std::string>()); + anime.SetUserProgress(attributes["/progress"_json_pointer].get<int>()); + anime.SetUserScore(attributes["/ratingTwenty"_json_pointer].get<int>() * 5); + anime.SetUserIsPrivate(attributes["/private"_json_pointer].get<bool>()); + anime.SetUserRewatchedTimes(attributes["/reconsumeCount"_json_pointer].get<int>()); + anime.SetUserIsRewatching(attributes["/reconsuming"_json_pointer].get<bool>()); /* "reconsuming". really? */ + // anime.SetUserStatus(); + // anime.SetUserLastUpdated(); + + return id; +} + +int GetAnimeList() { + return 0; +} + +/* unimplemented for now */ +std::vector<int> Search(const std::string& search) { + return {}; +} + +std::vector<int> GetSeason(Anime::SeriesSeason season, Date::Year year) { + return {}; +} + +int UpdateAnimeEntry(int id) { + return 0; +} + +bool AuthorizeUser(const std::string& email, const std::string& password) { + const nlohmann::json body = { + {"grant_type", "password"}, + {"username", email}, + {"password", HTTP::UrlEncode(password)} + }; + + if (!SendAuthRequest(body)) + return false; + + static const std::map<std::string, std::string> params = { + {"filter[self]", "true"} + }; + + std::optional<std::string> response = SendRequest("/users", params); + if (!response) + return false; // whuh? + + nlohmann::json json; + try { + json = nlohmann::json::parse(response.value()); + } catch (const std::exception& ex) { + session.SetStatusBar(std::string("Kitsu: Failed to parse user data with error \"") + ex.what() + "\"!"); + return false; + } + + if (!json.contains("/data/0/id"_json_pointer)) { + session.SetStatusBar("Kitsu: Failed to retrieve user ID!"); + return false; + } + + session.SetStatusBar("Kitsu: Successfully retrieved user data!"); + session.config.auth.kitsu.user_id = json["/data/0/id"_json_pointer].get<std::string>(); + + return true; +} + +} // namespace Kitsu +} // namespace Services
--- a/src/services/services.cc Tue Jun 11 15:11:09 2024 -0400 +++ b/src/services/services.cc Wed Jun 12 04:07:10 2024 -0400 @@ -1,13 +1,14 @@ #include "services/services.h" #include "core/session.h" -#include "gui/dialog/settings.h" #include "services/anilist.h" +#include "services/kitsu.h" namespace Services { void Synchronize() { switch (session.config.service) { case Anime::Service::AniList: AniList::GetAnimeList(); break; + case Anime::Service::Kitsu: Kitsu::GetAnimeList(); break; default: break; } } @@ -15,6 +16,7 @@ std::vector<int> Search(const std::string& search) { switch (session.config.service) { case Anime::Service::AniList: return AniList::Search(search); + case Anime::Service::Kitsu: return Kitsu::Search(search); default: return {}; } } @@ -22,6 +24,7 @@ std::vector<int> GetSeason(Anime::SeriesSeason season, Date::Year year) { switch (session.config.service) { case Anime::Service::AniList: return AniList::GetSeason(season, year); + case Anime::Service::Kitsu: return Kitsu::GetSeason(season, year); default: return {}; } } @@ -29,15 +32,9 @@ void UpdateAnimeEntry(int id) { switch (session.config.service) { case Anime::Service::AniList: AniList::UpdateAnimeEntry(id); break; + case Anime::Service::Kitsu: Kitsu::UpdateAnimeEntry(id); break; default: break; } } -bool Authorize() { - switch (session.config.service) { - case Anime::Service::AniList: return AniList::AuthorizeUser(); - default: return true; - } -} - }; // namespace Services
--- a/src/track/media.cc Tue Jun 11 15:11:09 2024 -0400 +++ b/src/track/media.cc Wed Jun 12 04:07:10 2024 -0400 @@ -41,8 +41,6 @@ for (const auto& result : results) { for (const auto& media : result.media) { for (const auto& info : media.information) { - std::cout << info.value << std::endl; - switch (info.type) { case animone::MediaInfoType::File: vec.push_back(std::filesystem::path(info.value).filename().u8string());