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());