changeset 114:ab191e28e69d

*: add initial torrent stuff WOAH! these checkboxes are a pain in my fucking ass
author Paper <mrpapersonic@gmail.com>
date Tue, 07 Nov 2023 08:03:42 -0500
parents 32afe0e940bf
children c72b907b9bef
files CMakeLists.txt include/core/config.h include/core/strings.h include/core/torrent.h include/gui/pages/anime_list.h include/gui/pages/torrents.h include/gui/window.h include/track/media.h rc/icons.qrc rc/icons/16x16/arrow-circle-315.png rc/icons/16x16/cross-button.png rc/icons/16x16/gear.png rc/icons/16x16/navigation-270-button.png src/core/strings.cc src/gui/pages/anime_list.cc src/gui/pages/torrents.cc src/gui/window.cc src/track/media.cc
diffstat 18 files changed, 809 insertions(+), 261 deletions(-) [+]
line wrap: on
line diff
--- a/CMakeLists.txt	Mon Nov 06 13:48:11 2023 -0500
+++ b/CMakeLists.txt	Tue Nov 07 08:03:42 2023 -0500
@@ -12,14 +12,7 @@
 option(BUILD_SHARED_LIBS "Build using shared libraries" ON)
 option(USE_QT6 "Force build with Qt 6" OFF)
 option(USE_QT5 "Force build with Qt 5" OFF)
-# The reason I'm not specifying this an an option() is that
-# that will *save the value*, which causes the *.qm translation
-# files to not automatically be generated, screwing up the whole
-# "automation" part of it.
-#
-# Ugh.
-#
-# option(UPDATE_TRANSLATIONS "Update *.ts translation files" OFF)
+option(UPDATE_TRANSLATIONS "Update *.ts translation files" OFF)
 
 add_subdirectory(dep/anitomy)
 add_subdirectory(dep/animia)
@@ -45,6 +38,7 @@
 	${Qt${QT_VERSION_MAJOR}Widgets_LIBRARIES}
 	anitomy
 	animia
+	pugixml
 )
 
 # We need Cocoa for some OS X stuff
--- a/include/core/config.h	Mon Nov 06 13:48:11 2023 -0500
+++ b/include/core/config.h	Tue Nov 07 08:03:42 2023 -0500
@@ -33,7 +33,13 @@
 
 #define WIDEIFY_EX(x) L##x
 #define WIDEIFY(x)    WIDEIFY_EX(x)
+
+#if (defined(WIN32) || defined(MACOSX))
+#define CONFIG_DIR    "Minori"
+#else
 #define CONFIG_DIR    "minori"
+#endif
+
 #define CONFIG_WDIR   WIDEIFY(CONFIG_DIR)
 #define CONFIG_NAME   "config.ini"
 #define CONFIG_WNAME  WIDEIFY(CONFIG_NAME)
--- a/include/core/strings.h	Mon Nov 06 13:48:11 2023 -0500
+++ b/include/core/strings.h	Tue Nov 07 08:03:42 2023 -0500
@@ -4,72 +4,12 @@
 #include <string>
 #include <vector>
 #include <array>
+#include <cstdint>
 
 class QString;
 
 namespace Strings {
 
-template<unsigned...>struct seq{using type=seq;};
-template<unsigned N, unsigned... Is>
-struct gen_seq_x : gen_seq_x<N-1, N-1, Is...>{};
-template<unsigned... Is>
-struct gen_seq_x<0, Is...> : seq<Is...>{};
-template<unsigned N>
-using gen_seq=typename gen_seq_x<N>::type;
-
-template<size_t S>
-using size=std::integral_constant<size_t, S>;
-
-template<class T, size_t N>
-constexpr size<N> length( T const(&)[N] ) { return {}; }
-template<class T, size_t N>
-constexpr size<N> length( std::array<T, N> const& ) { return {}; }
-
-template<class T>
-using length_t = decltype(length(std::declval<T>()));
-
-constexpr size_t string_size() { return 0; }
-template<class...Ts>
-constexpr size_t string_size( size_t i, Ts... ts ) {
-  return (i?i-1:0) + string_size(ts...);
-}
-template<class...Ts>
-using string_length=size< string_size( length_t<Ts>{}... )>;
-
-template<class...Ts>
-using combined_string = std::array<char, string_length<Ts...>{}+1>;
-
-template<class Lhs, class Rhs, unsigned...I1, unsigned...I2>
-constexpr const combined_string<Lhs,Rhs>
-concat_impl( Lhs const& lhs, Rhs const& rhs, seq<I1...>, seq<I2...>)
-{
-    return {{ lhs[I1]..., rhs[I2]..., '\0' }};
-}
-
-template<class Lhs, class Rhs>
-constexpr const combined_string<Lhs,Rhs>
-concat(Lhs const& lhs, Rhs const& rhs)
-{
-    return concat_impl(lhs, rhs, gen_seq<string_length<Lhs>{}>{}, gen_seq<string_length<Rhs>{}>{});
-}
-
-template<class T0, class T1, class... Ts>
-constexpr const combined_string<T0, T1, Ts...>
-concat(T0 const&t0, T1 const&t1, Ts const&...ts)
-{
-    return concat(t0, concat(t1, ts...));
-}
-
-template<class T>
-constexpr const combined_string<T>
-concat(T const&t) {
-    return concat(t, "");
-}
-constexpr const combined_string<>
-concat() {
-    return concat("");
-}
-
 /* Implode function: takes a vector of strings and turns it
    into a string, separated by delimiters. */
 std::string Implode(const std::vector<std::string>& vector, const std::string& delimiter);
@@ -98,6 +38,11 @@
 /* arithmetic :) */
 int ToInt(const std::string& str, int def = 0);
 
+uint64_t HumanReadableSizeToBytes(const std::string& str);
+
+std::string RemoveLeadingChars(std::string s, const char c);
+std::string RemoveTrailingChars(std::string s, const char c);
+
 bool BeginningMatchesSubstring(const std::string& str, const std::string& sub);
 
 }; // namespace Strings
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/include/core/torrent.h	Tue Nov 07 08:03:42 2023 -0500
@@ -0,0 +1,56 @@
+#ifndef __core__torrent_h
+#define __core__torrent_h
+
+#include <string>
+#include <QDateTime>
+
+/* this will be moved into its own namespace if
+   it's deemed necessary */
+class Torrent {
+	public:
+		std::string GetTitle() const { return _title; };
+		std::string GetEpisode() const { return _episode; };
+		std::string GetGroup() const { return _group; };
+		size_t GetSize() const { return _size; };
+		std::string GetResolution() const { return _resolution; };
+		int GetSeeders() const { return _seeders; };
+		int GetLeechers() const { return _leechers; };
+		int GetDownloaders() const { return _downloaders; };
+		std::string GetDescription() const { return _description; };
+		std::string GetFilename() const { return _filename; };
+		std::string GetLink() const { return _link; };
+		std::string GetGuid() const { return _guid; };
+		QDateTime GetDate() const { return _date; };
+
+		void SetTitle(const std::string& title) { _title = title; };
+		void SetEpisode(const std::string& episode) { _episode = episode; };
+		void SetGroup(const std::string& group) { _group = group; };
+		void SetSize(const size_t size) { _size = size; };
+		void SetResolution(const std::string& resolution) { _resolution = resolution; };
+		void SetSeeders(const int seeders) { _seeders = seeders; };
+		void SetLeechers(const int leechers) { _leechers = leechers; };
+		void SetDownloaders(const int downloaders) { _downloaders = downloaders; };
+		void SetDescription(const std::string& description) { _description = description; };
+		void SetFilename(const std::string& filename) { _filename = filename; };
+		void SetLink(const std::string& link) { _link = link; };
+		void SetGuid(const std::string& guid) { _guid = guid; };
+		void SetDate(const QDateTime& date) { _date = date; };
+
+	private:
+		std::string _title;
+		std::string _episode;
+		std::string _group;
+		size_t _size = 0;
+		std::string _resolution; /* technically should be an int,
+		                            but std::string is more useful */
+		int _seeders = 0;
+		int _leechers = 0;
+		int _downloaders = 0;
+		std::string _description;
+		std::string _filename;
+		std::string _link;
+		std::string _guid;
+		QDateTime _date;
+};
+
+#endif // __core__torrent_h
\ No newline at end of file
--- a/include/gui/pages/anime_list.h	Mon Nov 06 13:48:11 2023 -0500
+++ b/include/gui/pages/anime_list.h	Tue Nov 07 08:03:42 2023 -0500
@@ -41,7 +41,7 @@
 			NB_COLUMNS
 		};
 
-		AnimeListPageModel(QWidget* parent, Anime::ListStatus _status);
+		AnimeListPageModel(QObject* parent, Anime::ListStatus _status);
 		~AnimeListPageModel() override = default;
 		int rowCount(const QModelIndex& parent = QModelIndex()) const override;
 		int columnCount(const QModelIndex& parent = QModelIndex()) const override;
@@ -63,7 +63,6 @@
 	public:
 		AnimeListPage(QWidget* parent);
 		void Refresh();
-		void Reset();
 
 	protected:
 		void paintEvent(QPaintEvent*) override;
--- a/include/gui/pages/torrents.h	Mon Nov 06 13:48:11 2023 -0500
+++ b/include/gui/pages/torrents.h	Tue Nov 07 08:03:42 2023 -0500
@@ -1,13 +1,77 @@
 #ifndef __gui__pages__torrents_h
 #define __gui__pages__torrents_h
 
-#include <QWidget>
+#include "core/torrent.h"
+#include <QFrame>
+#include <QAbstractListModel>
+#include <QSortFilterProxyModel>
+
+class TorrentModelItem : public Torrent {
+	public:
+		bool GetChecked() const { return _checked; };
+		void SetChecked(bool checked) { _checked = checked; };
+
+	private:
+		bool _checked = false;
+};
+
+class TorrentsPageListSortFilter final : public QSortFilterProxyModel {
+		Q_OBJECT
+
+	public:
+		TorrentsPageListSortFilter(QObject* parent = nullptr);
+
+	protected:
+		bool lessThan(const QModelIndex& l, const QModelIndex& r) const override;
+};
+
+class TorrentsPageListModel final : public QAbstractListModel {
+		Q_OBJECT
 
-class TorrentsPage final : public QWidget {
+	public:
+		enum columns {
+			TL_TITLE,
+			TL_EPISODE,
+			TL_GROUP,
+			TL_SIZE,
+			TL_RESOLUTION,
+			TL_SEEDERS, 
+			TL_LEECHERS,
+			TL_DOWNLOADERS,
+			TL_DESCRIPTION,
+			TL_FILENAME,
+			TL_RELEASEDATE,
+
+			NB_COLUMNS
+		};
+
+		TorrentsPageListModel(QObject* parent);
+		~TorrentsPageListModel() override = default;
+		int rowCount(const QModelIndex& parent = QModelIndex()) const override;
+		int columnCount(const QModelIndex& parent = QModelIndex()) const override;
+		bool setData(const QModelIndex& index, const QVariant& value, int role) override;
+		QVariant data(const QModelIndex& index, int role) const override;
+		QVariant headerData(const int section, const Qt::Orientation orientation, const int role) const override;
+		Qt::ItemFlags flags(const QModelIndex& index) const override;
+
+		QByteArray DownloadTorrentList();
+		void ParseTorrentList(const QByteArray& ba);
+		void RefreshTorrentList();
+
+	private:
+		std::vector<TorrentModelItem> list;
+};
+
+class TorrentsPage final : public QFrame {
 		Q_OBJECT
 
 	public:
 		TorrentsPage(QWidget* parent = nullptr);
+		void Refresh();
+
+	private:
+		TorrentsPageListModel* model = nullptr;
+		TorrentsPageListSortFilter* sort_model = nullptr;
 };
 
 #endif // __gui__pages__torrents_h
--- a/include/gui/window.h	Mon Nov 06 13:48:11 2023 -0500
+++ b/include/gui/window.h	Tue Nov 07 08:03:42 2023 -0500
@@ -2,11 +2,15 @@
 #define __window_h
 #include "core/config.h"
 #include <QMainWindow>
+#include <memory>
 
-class QWidget;
-class QStackedWidget;
-class QCloseEvent;
-class SideBar;
+/* *could* be forward-declared, but this causes
+   any file that #includes this to have to #include
+   these as well due to unique_ptr */
+#include <QWidget>
+#include <QStackedWidget>
+#include <QCloseEvent>
+#include "gui/widgets/sidebar.h"
 
 class MainWindow final : public QMainWindow {
 		Q_OBJECT
@@ -23,9 +27,9 @@
 		void closeEvent(QCloseEvent* event) override;
 
 	private:
-		QWidget* main_widget = nullptr;
-		QStackedWidget* stack = nullptr;
-		SideBar* sidebar = nullptr;
+		std::unique_ptr<QWidget> main_widget = nullptr;
+		std::unique_ptr<QStackedWidget> stack = nullptr;
+		std::unique_ptr<SideBar> sidebar = nullptr;
 };
 
 #endif // __window_h
--- a/include/track/media.h	Mon Nov 06 13:48:11 2023 -0500
+++ b/include/track/media.h	Tue Nov 07 08:03:42 2023 -0500
@@ -7,6 +7,7 @@
 namespace Media {
 
 Filesystem::Path GetCurrentPlaying();
+std::unordered_map<std::string, std::string> GetFileElements(std::string basename);
 std::unordered_map<std::string, std::string> GetFileElements(Filesystem::Path path);
 
 } // namespace Media
--- a/rc/icons.qrc	Mon Nov 06 13:48:11 2023 -0500
+++ b/rc/icons.qrc	Tue Nov 07 08:03:42 2023 -0500
@@ -1,13 +1,17 @@
 <!DOCTYPE rcc><RCC version="1.0">
 	<qresource>
 		<file>favicon.png</file>
+		<file>icons/16x16/arrow-circle-315.png</file>
 		<file>icons/16x16/calendar.png</file>
 		<file>icons/16x16/chart.png</file>
 		<file>icons/16x16/clock-history-frame.png</file>
+		<file>icons/16x16/cross-button.png</file>
 		<file>icons/16x16/document-list.png</file>
 		<file>icons/16x16/feed.png</file>
 		<file>icons/16x16/film.png</file>
+		<file>icons/16x16/gear.png</file>
 		<file>icons/16x16/magnifier.png</file>
+		<file>icons/16x16/navigation-270-button.png</file>
 		<file>icons/24x24/application-export.png</file>
 		<file>icons/24x24/application-sidebar-list.png</file>
 		<file>icons/24x24/arrow-circle-double-135.png</file>
Binary file rc/icons/16x16/arrow-circle-315.png has changed
Binary file rc/icons/16x16/cross-button.png has changed
Binary file rc/icons/16x16/gear.png has changed
Binary file rc/icons/16x16/navigation-270-button.png has changed
--- a/src/core/strings.cc	Mon Nov 06 13:48:11 2023 -0500
+++ b/src/core/strings.cc	Tue Nov 07 08:03:42 2023 -0500
@@ -41,7 +41,17 @@
 }
 
 std::string SanitizeLineEndings(const std::string& string) {
-	return ReplaceAll(ReplaceAll(ReplaceAll(string, "\r\n", "\n"), "<br>", "\n"), "\n\n\n", "\n\n");
+	/* LOL */
+	return
+		ReplaceAll(
+			ReplaceAll(
+				ReplaceAll(
+					ReplaceAll(
+						ReplaceAll(string, "\r\n", "\n"),
+					"</p>", "\n"),
+				"<br>", "\n"),
+			"<br />", "\n"),
+		"\n\n\n", "\n\n");
 }
 
 /* removes dumb HTML tags because anilist is aids and
@@ -146,6 +156,39 @@
 	return tmp;
 }
 
+uint64_t HumanReadableSizeToBytes(const std::string& str) {
+	const std::unordered_map<std::string, uint64_t> bytes_map = {
+		{"KB", 1 << 10},
+		{"MB", 1 << 20},
+		{"GB", 1 << 30},
+		{"TB", 1 << 40},
+		{"PB", 1 << 50} /* surely we won't need more than this */
+	};
+
+	for (const auto& suffix : bytes_map) {
+		if (str.find(suffix.first) != std::string::npos) {
+			try {
+				uint64_t size = std::stod(str) * suffix.second;
+				return size;
+			} catch (std::invalid_argument const& ex) {
+				continue;
+			}
+		}
+	}
+
+	return ToInt(str, 0);
+}
+
+std::string RemoveLeadingChars(std::string s, const char c) {
+    s.erase(0, std::min(s.find_first_not_of(c), s.size() - 1));
+    return s;
+}
+
+std::string RemoveTrailingChars(std::string s, const char c) {
+	s.erase(s.find_last_not_of(c) + 1, std::string::npos);
+	return s;
+}
+
 bool BeginningMatchesSubstring(const std::string& str, const std::string& sub) {
 	for (unsigned long long i = 0; i < str.length() && i < sub.length(); i++)
 		if (str[i] != sub[i])
--- a/src/gui/pages/anime_list.cc	Mon Nov 06 13:48:11 2023 -0500
+++ b/src/gui/pages/anime_list.cc	Tue Nov 07 08:03:42 2023 -0500
@@ -47,7 +47,9 @@
 	}
 }
 
-AnimeListPageModel::AnimeListPageModel(QWidget* parent, Anime::ListStatus _status) : QAbstractListModel(parent) {
+/* -------------------------------------------------- */
+
+AnimeListPageModel::AnimeListPageModel(QObject* parent, Anime::ListStatus _status) : QAbstractListModel(parent) {
 	status = _status;
 	return;
 }
@@ -180,6 +182,8 @@
 	endResetModel();
 }
 
+/* ----------------------------------------------------------------- */
+
 int AnimeListPage::VisibleColumnsCount() const {
 	int count = 0;
 
@@ -316,24 +320,24 @@
 	dialog->activateWindow();
 }
 
-void AnimeListPage::paintEvent(QPaintEvent*) {
-	QStylePainter p(this);
-
-	QStyleOptionTabWidgetFrame opt;
-	InitStyle(&opt);
-	opt.rect = panelRect;
-	p.drawPrimitive(QStyle::PE_FrameTabWidget, opt);
+void AnimeListPage::RefreshList() {
+	for (unsigned int i = 0; i < sort_models.size(); i++)
+		reinterpret_cast<AnimeListPageModel*>(sort_models[i]->sourceModel())->RefreshList();
 }
 
-void AnimeListPage::resizeEvent(QResizeEvent* e) {
-	QWidget::resizeEvent(e);
-	SetupLayout();
+void AnimeListPage::RefreshTabs() {
+	for (unsigned int i = 0; i < sort_models.size(); i++)
+		tab_bar->setTabText(i, Strings::ToQString(Translate::ToString(Anime::ListStatuses[i])) + " (" +
+		                           QString::number(Anime::db.GetListsAnimeAmount(Anime::ListStatuses[i])) + ")");
 }
 
-void AnimeListPage::showEvent(QShowEvent*) {
-	SetupLayout();
+void AnimeListPage::Refresh() {
+	RefreshList();
+	RefreshTabs();
 }
 
+/* -------- QTabWidget replication begin --------- */
+
 void AnimeListPage::InitBasicStyle(QStyleOptionTabWidgetFrame* option) const {
 	if (!option)
 		return;
@@ -378,6 +382,26 @@
 	tree_view->parentWidget()->setGeometry(contentsRect);
 }
 
+void AnimeListPage::paintEvent(QPaintEvent*) {
+	QStylePainter p(this);
+
+	QStyleOptionTabWidgetFrame opt;
+	InitStyle(&opt);
+	opt.rect = panelRect;
+	p.drawPrimitive(QStyle::PE_FrameTabWidget, opt);
+}
+
+void AnimeListPage::resizeEvent(QResizeEvent* e) {
+	QWidget::resizeEvent(e);
+	SetupLayout();
+}
+
+void AnimeListPage::showEvent(QShowEvent*) {
+	SetupLayout();
+}
+
+/* --------- QTabWidget replication end ---------- */
+
 AnimeListPage::AnimeListPage(QWidget* parent) : QWidget(parent) {
 	/* Tab bar */
 	tab_bar = new QTabBar(this);
@@ -438,30 +462,4 @@
 	Refresh();
 }
 
-void AnimeListPage::RefreshList() {
-	for (unsigned int i = 0; i < sort_models.size(); i++)
-		reinterpret_cast<AnimeListPageModel*>(sort_models[i]->sourceModel())->RefreshList();
-}
-
-void AnimeListPage::RefreshTabs() {
-	for (unsigned int i = 0; i < sort_models.size(); i++)
-		tab_bar->setTabText(i, Strings::ToQString(Translate::ToString(Anime::ListStatuses[i])) + " (" +
-		                           QString::number(Anime::db.GetListsAnimeAmount(Anime::ListStatuses[i])) + ")");
-}
-
-void AnimeListPage::Refresh() {
-	RefreshList();
-	RefreshTabs();
-}
-
-/* This function, really, really should not be called.
-   Ever. Why would you ever need to clear the anime list?
-   Also, this sucks. */
-void AnimeListPage::Reset() {
-	while (tab_bar->count())
-		tab_bar->removeTab(0);
-	for (unsigned int i = 0; i < sort_models.size(); i++)
-		delete sort_models[i];
-}
-
 #include "gui/pages/moc_anime_list.cpp"
--- a/src/gui/pages/torrents.cc	Mon Nov 06 13:48:11 2023 -0500
+++ b/src/gui/pages/torrents.cc	Tue Nov 07 08:03:42 2023 -0500
@@ -1,6 +1,331 @@
 #include "gui/pages/torrents.h"
+#include "core/strings.h"
+#include "core/http.h"
+#include "core/session.h"
+#include "gui/widgets/text.h"
+#include "track/media.h"
+#include "pugixml.hpp"
+#include <QVBoxLayout>
+#include <QToolBar>
+#include <QTreeView>
+#include <QMainWindow>
+#include <QByteArray>
+#include <QDataStream>
+#include <QThreadPool>
+#include <QDebug>
+#include <iostream>
+#include <sstream>
+#include <algorithm>
+
+TorrentsPageListSortFilter::TorrentsPageListSortFilter(QObject* parent) : QSortFilterProxyModel(parent) {
+}
+
+bool TorrentsPageListSortFilter::lessThan(const QModelIndex& l, const QModelIndex& r) const {
+	QVariant left = sourceModel()->data(l, sortRole());
+	QVariant right = sourceModel()->data(r, sortRole());
+
+	switch (left.userType()) {
+		case QMetaType::Int:
+		case QMetaType::UInt:
+		case QMetaType::LongLong:
+		case QMetaType::ULongLong: return left.toInt() < right.toInt();
+		case QMetaType::QDate: return left.toDate() < right.toDate();
+		case QMetaType::QString:
+		default: return QString::compare(left.toString(), right.toString(), Qt::CaseInsensitive) < 0;
+	}
+}
+
+/* -------------------------------------------- */
+
+TorrentsPageListModel::TorrentsPageListModel(QObject* parent) : QAbstractListModel(parent) {
+}
+
+QByteArray TorrentsPageListModel::DownloadTorrentList() {
+	return HTTP::Get("https://www.tokyotosho.info/rss.php?filter=1,11&zwnj=0");
+}
+
+void TorrentsPageListModel::ParseTorrentList(const QByteArray& ba) {
+	std::istringstream stdstream(Strings::ToUtf8String(ba));
+
+	pugi::xml_document doc;
+	if (!doc.load(stdstream))
+		return; // peace out
+
+	/* my extra special dumb hack. */
+	if (!rowCount(index(0))) {
+		beginInsertRows(QModelIndex(), 0, 0);
+		endInsertRows();
+	}
+
+	beginResetModel();
+
+	list.clear();
+	/* this is just an rss parser; it should be in a separate class... */
+	for (pugi::xml_node item : doc.child("rss").child("channel").children("item")) {
+		TorrentModelItem torrent;
+		torrent.SetFilename(item.child_value("title")); /* "title" == filename */
+		{
+			/* Use Anitomy to parse the file's elements (we should *really* not be doing this this way!) */
+			std::unordered_map<std::string, std::string> elements = Track::Media::GetFileElements(torrent.GetFilename());
+			torrent.SetTitle(elements["title"]);
+			torrent.SetEpisode(Strings::RemoveLeadingChars(elements["episode"], '0'));
+			torrent.SetGroup(elements["group"]);
+			torrent.SetResolution(elements["resolution"]);
+		}
+		torrent.SetDescription(Strings::TextifySynopsis(item.child_value("description")));
+		{
+			/* Parse size from description */
+			std::istringstream descstream(torrent.GetDescription());
+
+			for (std::string line; std::getline(descstream, line);) {
+				const std::string match = "Size: ";
+				size_t pos = line.find(match);
+
+				if (!pos) {
+					const std::string size = line.substr(pos + match.length());
+					torrent.SetSize(Strings::HumanReadableSizeToBytes(size));
+				}
+			}
+		}
+		torrent.SetLink(item.child_value("link"));
+		torrent.SetGuid(item.child_value("guid"));
+		{
+			const QString date_str = Strings::ToQString(item.child_value("pubDate"));
+			torrent.SetDate(QDateTime::fromString(date_str, "ddd, dd MMM yyyy HH:mm:ss t"));
+		}
+		list.push_back(torrent);
+	}
+
+	endResetModel();
+}
+
+void TorrentsPageListModel::RefreshTorrentList() {
+	ParseTorrentList(DownloadTorrentList());
+}
+
+int TorrentsPageListModel::rowCount(const QModelIndex& parent) const {
+	return list.size();
+	(void)(parent);
+}
+
+int TorrentsPageListModel::columnCount(const QModelIndex& parent) const {
+	return NB_COLUMNS;
+	(void)(parent);
+}
+
+QVariant TorrentsPageListModel::headerData(const int section, const Qt::Orientation orientation, const int role) const {
+	switch (role) {
+		case Qt::DisplayRole: {
+			switch (section) {
+				case TL_TITLE: return tr("Anime title");
+				case TL_EPISODE: return tr("Episode");
+				case TL_GROUP: return tr("Group");
+				case TL_SIZE: return tr("Size");
+				case TL_RESOLUTION: return tr("Resolution"); /* this is named "Video" in Taiga */
+				case TL_SEEDERS: return tr("Seeding"); /* named "S" in Taiga */
+				case TL_LEECHERS: return tr("Leeching"); /* named "L" in Taiga */
+				case TL_DOWNLOADERS: return tr("Downloading"); /* named "D" in Taiga */
+				case TL_DESCRIPTION: return tr("Description");
+				case TL_FILENAME: return tr("Filename");
+				case TL_RELEASEDATE: return tr("Release date");
+				default: return {};
+			}
+			break;
+		}
+		case Qt::TextAlignmentRole: {
+			switch (section) {
+				case TL_FILENAME:
+				case TL_GROUP:
+				case TL_DESCRIPTION:
+				case TL_RESOLUTION:
+				case TL_TITLE: return QVariant(Qt::AlignLeft | Qt::AlignVCenter);
+				case TL_SEEDERS:
+				case TL_LEECHERS:
+				case TL_DOWNLOADERS:
+				case TL_SIZE:
+				case TL_EPISODE:
+				case TL_RELEASEDATE: return QVariant(Qt::AlignRight | Qt::AlignVCenter);
+				default: return {};
+			}
+			break;
+		}
+	}
+	return QAbstractListModel::headerData(section, orientation, role);
+}
+
+bool TorrentsPageListModel::setData(const QModelIndex& index, const QVariant& value, int role) {
+	TorrentModelItem& item = list.at(index.row());
 
-TorrentsPage::TorrentsPage(QWidget* parent) : QWidget(parent) {
+	if (index.column() == 0) {
+		switch (role) {
+			case Qt::EditRole:
+				return false;
+			case Qt::CheckStateRole:
+				item.SetChecked(value.toBool());
+				emit dataChanged(index, index);
+				return true;
+		}
+	}
+
+	return QAbstractItemModel::setData(index, value, role);
+}
+
+QVariant TorrentsPageListModel::data(const QModelIndex& index, int role) const {
+	if (!index.isValid())
+		return QVariant();
+	switch (role) {
+		case Qt::DisplayRole:
+			switch (index.column()) {
+				case TL_TITLE: return Strings::ToQString(list.at(index.row()).GetTitle());
+				case TL_EPISODE: return Strings::ToQString(list.at(index.row()).GetEpisode());
+				case TL_GROUP: return Strings::ToQString(list.at(index.row()).GetGroup());
+				case TL_SIZE: return session.config.locale.GetLocale().formattedDataSize(list.at(index.row()).GetSize());
+				case TL_RESOLUTION: return Strings::ToQString(list.at(index.row()).GetResolution());
+				case TL_SEEDERS: return list.at(index.row()).GetSeeders();
+				case TL_LEECHERS: return list.at(index.row()).GetLeechers();
+				case TL_DOWNLOADERS: return list.at(index.row()).GetDownloaders();
+				case TL_DESCRIPTION: return Strings::ToQString(list.at(index.row()).GetDescription());
+				case TL_FILENAME: return Strings::ToQString(list.at(index.row()).GetFilename());
+				case TL_RELEASEDATE: return list.at(index.row()).GetDate();
+				default: return "";
+			}
+			break;
+		case Qt::UserRole:
+			switch (index.column()) {
+				case TL_EPISODE: return Strings::ToInt(list.at(index.row()).GetEpisode(), -1);
+				case TL_SIZE: return list.at(index.row()).GetSize();
+				default: return data(index, Qt::DisplayRole);
+			}
+			break;
+		case Qt::CheckStateRole:
+			switch (index.column()) {
+				case 0: return list.at(index.row()).GetChecked() ? Qt::Checked : Qt::Unchecked;
+				default: return {};
+			}
+		case Qt::SizeHintRole: {
+			switch (index.column()) {
+				default: {
+					const QString d = data(index, Qt::DisplayRole).toString();
+					const QFontMetrics metric = QFontMetrics(QFont());
+
+					return QSize(std::max(metric.horizontalAdvance(d), 100), metric.height());
+				}
+			}
+			break;
+		}
+		case Qt::TextAlignmentRole:
+			switch (index.column()) {
+				case TL_FILENAME:
+				case TL_GROUP:
+				case TL_DESCRIPTION:
+				case TL_RESOLUTION:
+				case TL_TITLE: return QVariant(Qt::AlignLeft | Qt::AlignVCenter);
+				case TL_SEEDERS:
+				case TL_LEECHERS:
+				case TL_DOWNLOADERS:
+				case TL_SIZE:
+				case TL_EPISODE:
+				case TL_RELEASEDATE: return QVariant(Qt::AlignRight | Qt::AlignVCenter);
+				default: return {};
+			}
+			break;
+	}
+	return QVariant();
+}
+
+Qt::ItemFlags TorrentsPageListModel::flags(const QModelIndex& index) const {
+	if (!index.isValid())
+		return Qt::NoItemFlags;
+
+	const TorrentModelItem& item = list.at(index.row());
+
+	if (item.GetChecked() || index.column() == 0)
+		return Qt::ItemIsEnabled | Qt::ItemIsSelectable | Qt::ItemIsUserCheckable;
+	else
+		return Qt::ItemIsSelectable | Qt::ItemIsUserCheckable;
+}
+
+TorrentsPage::TorrentsPage(QWidget* parent) : QFrame(parent) {
+	setFrameShape(QFrame::Box);
+	setFrameShadow(QFrame::Sunken);
+
+	QVBoxLayout* layout = new QVBoxLayout(this);
+	layout->setContentsMargins(0, 0, 0, 0);
+	layout->setSpacing(0);
+
+	{
+		/* Toolbar */
+		QToolBar* toolbar = new QToolBar(this);
+		toolbar->setToolButtonStyle(Qt::ToolButtonTextBesideIcon);
+		toolbar->setIconSize(QSize(16, 16));
+		toolbar->setMovable(false);
+
+		{
+			/* this needs to be stored somewhere to replicate Taiga's
+			   "timer" feature */
+			QAction* action = toolbar->addAction(QIcon(":/icons/16x16/arrow-circle-315.png"), tr("&Check new torrents"), [this]{
+				QThreadPool::globalInstance()->start([this] {
+					Refresh();
+				});
+			});
+		}
+
+		toolbar->addSeparator();
+
+		{
+			QAction* action = toolbar->addAction(QIcon(":/icons/16x16/navigation-270-button.png"), tr("Download &marked torrents"));
+		}
+
+		{
+			QAction* action = toolbar->addAction(QIcon(":/icons/16x16/cross-button.png"), tr("&Discard all"));
+		}
+
+		toolbar->addSeparator();
+
+		{
+			QAction* action = toolbar->addAction(QIcon(":/icons/16x16/gear.png"), tr("&Settings"));
+		}
+
+		layout->addWidget(toolbar);
+	}
+
+	{
+		QFrame* line = new QFrame(this);
+		line->setFrameShape(QFrame::HLine);
+		line->setFrameShadow(QFrame::Sunken);
+		line->setLineWidth(1);
+		layout->addWidget(line);
+	}
+
+	{
+		QTreeView* treeview = new QTreeView(this);
+		treeview->setUniformRowHeights(true);
+		treeview->setAllColumnsShowFocus(false);
+		treeview->setAlternatingRowColors(true);
+		treeview->setSortingEnabled(true);
+		treeview->setSelectionMode(QAbstractItemView::ExtendedSelection);
+		treeview->setItemsExpandable(false);
+		treeview->setRootIsDecorated(false);
+		treeview->setContextMenuPolicy(Qt::CustomContextMenu);
+		treeview->setFrameShape(QFrame::NoFrame);
+
+		{
+			sort_model = new TorrentsPageListSortFilter(treeview);
+			model = new TorrentsPageListModel(treeview);
+			sort_model->setSourceModel(model);
+			sort_model->setSortRole(Qt::UserRole);
+			sort_model->setSortCaseSensitivity(Qt::CaseInsensitive);
+			treeview->setModel(sort_model);
+		}
+
+		layout->addWidget(treeview);
+	}
+}
+
+void TorrentsPage::Refresh() {
+	if (!model)
+		return;
+	model->RefreshTorrentList();
 }
 
 #include "gui/pages/moc_torrents.cpp"
--- a/src/gui/window.cc	Mon Nov 06 13:48:11 2023 -0500
+++ b/src/gui/window.cc	Tue Nov 07 08:03:42 2023 -0500
@@ -53,12 +53,12 @@
 MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) {
 	setWindowIcon(QIcon(":/favicon.png"));
 
-	main_widget = new QWidget(this);
-	/*QHBoxLayout* layout = */new QHBoxLayout(main_widget);
+	main_widget.reset(new QWidget(this));
+	/*QHBoxLayout* layout = */new QHBoxLayout(main_widget.get());
 
 	AddMainWidgets();
 
-	setCentralWidget(main_widget);
+	setCentralWidget(main_widget.get());
 
 	CreateBars();
 
@@ -81,18 +81,17 @@
 
 void MainWindow::AddMainWidgets() {
 	int page = static_cast<int>(Pages::ANIME_LIST);
-	if (sidebar) {
-		main_widget->layout()->removeWidget(sidebar);
-		delete sidebar;
+	if (sidebar.get()) {
+		main_widget->layout()->removeWidget(sidebar.get());
+		sidebar.reset();
 	}
 
-	if (stack) {
+	if (stack.get()) {
 		page = stack->currentIndex();
-		main_widget->layout()->removeWidget(stack);
-		delete stack;
+		main_widget->layout()->removeWidget(stack.get());
 	}
 
-	sidebar = new SideBar(main_widget);
+	sidebar.reset(new SideBar(main_widget.get()));
 	sidebar->setFixedWidth(128);
 	sidebar->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Expanding);
 
@@ -106,182 +105,284 @@
 	sidebar->AddItem(tr("Seasons"), SideBar::CreateIcon(":/icons/16x16/calendar.png"));
 	sidebar->AddItem(tr("Torrents"), SideBar::CreateIcon(":/icons/16x16/feed.png"));
 
-	stack = new QStackedWidget(main_widget);
-	stack->addWidget(new NowPlayingPage(main_widget));
-	stack->addWidget(new AnimeListPage(main_widget));
-	stack->addWidget(new HistoryPage(main_widget));
-	stack->addWidget(new StatisticsPage(main_widget));
-	stack->addWidget(new SearchPage(main_widget));
-	stack->addWidget(new SeasonsPage(main_widget));
-	stack->addWidget(new TorrentsPage(main_widget));
+	stack.reset(new QStackedWidget(main_widget.get()));
+	stack->addWidget(new NowPlayingPage(main_widget.get()));
+	stack->addWidget(new AnimeListPage(main_widget.get()));
+	stack->addWidget(new HistoryPage(main_widget.get()));
+	stack->addWidget(new StatisticsPage(main_widget.get()));
+	stack->addWidget(new SearchPage(main_widget.get()));
+	stack->addWidget(new SeasonsPage(main_widget.get()));
+	stack->addWidget(new TorrentsPage(main_widget.get()));
 
-	connect(sidebar, &SideBar::CurrentItemChanged, stack, &QStackedWidget::setCurrentIndex);
+	connect(sidebar.get(), &SideBar::CurrentItemChanged, stack.get(), &QStackedWidget::setCurrentIndex);
 	sidebar->SetCurrentItem(page);
 
-	main_widget->layout()->addWidget(sidebar);
-	main_widget->layout()->addWidget(stack);
+	main_widget->layout()->addWidget(sidebar.get());
+	main_widget->layout()->addWidget(stack.get());
 }
 
 void MainWindow::CreateBars() {
-	/* Menu Bar */
-	QAction* action;
+	/* Menu Bar
+	   The notation of these might seem ugly at first, but it's actually very nice
+	   (just trust me). It makes it much easier to edit the lists and makes it clear
+	   if you're in submenu or not. */
 	QMenuBar* menubar = new QMenuBar(this);
-	QMenu* menu = menubar->addMenu(tr("&File"));
 
-	QMenu* submenu = menu->addMenu(tr("&Library folders"));
-	action = submenu->addAction(tr("&Add new folder..."));
+	{
+		/* File */
+		QMenu* menu = menubar->addMenu(tr("&File"));
 
-	action = menu->addAction(tr("&Scan available episodes"));
-
-	menu->addSeparator();
+		{
+			QMenu* submenu = menu->addMenu(tr("&Library folders"));
+			{
+				QAction* action = submenu->addAction(tr("&Add new folder..."));
+			}
+		}
 
-	action = menu->addAction(tr("Play &next episode"));
-	action->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_N));
-	action = menu->addAction(tr("Play &random episode"));
-	action->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_R));
+		{
+			QAction* action = menu->addAction(tr("&Scan available episodes"));
+		}
 
-	menu->addSeparator();
+		menu->addSeparator();
 
-	action = menu->addAction(tr("E&xit"), qApp, &QApplication::quit);
+//		{
+//			QAction* action = menu->addAction(tr("Play &next episode"));
+//			action->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_N));
+//		}
+//
+//		{
+//			QAction* action = menu->addAction(tr("Play &random episode"));
+//			action->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_R));
+//		}
 
-	menu = menubar->addMenu(tr("&Services"));
-	action = menu->addAction(tr("Synchronize &list"), [this] { AsyncSynchronize(stack); });
-	action->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_S));
-
-	menu->addSeparator();
+		menu->addSeparator();
 
-	submenu = menu->addMenu(tr("&AniList"));
-	action = submenu->addAction(tr("Go to my &profile"));
-	action = submenu->addAction(tr("Go to my &stats"));
+		{
+			QAction* action = menu->addAction(tr("E&xit"), qApp, &QApplication::quit);
+		}
+	}
 
-	submenu = menu->addMenu(tr("&Kitsu"));
-	action = submenu->addAction(tr("Go to my &feed"));
-	action = submenu->addAction(tr("Go to my &library"));
-	action = submenu->addAction(tr("Go to my &profile"));
-
-	submenu = menu->addMenu(tr("&MyAnimeList"));
-	action = submenu->addAction(tr("Go to my p&anel"));
-	action = submenu->addAction(tr("Go to my &profile"));
-	action = submenu->addAction(tr("Go to my &history"));
+	{
+		/* Services */
+		QMenu* menu = menubar->addMenu(tr("&Services"));
+		{
+			{
+				QAction* action = menu->addAction(tr("Synchronize &list"), [this] { AsyncSynchronize(stack.get()); });
+				action->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_S));
+			}
 
-	menu = menubar->addMenu(tr("&Tools"));
-	submenu = menu->addMenu(tr("&Export anime list"));
-	action = submenu->addAction(tr("Export as &Markdown..."));
-	action = submenu->addAction(tr("Export as MyAnimeList &XML..."));
-
-	menu->addSeparator();
-
-	action = menu->addAction(tr("Enable anime &recognition"));
-	action->setCheckable(true);
-	action = menu->addAction(tr("Enable auto &sharing"));
-	action->setCheckable(true);
-	action = menu->addAction(tr("Enable &auto synchronization"));
-	action->setCheckable(true);
-
-	menu->addSeparator();
+//			menu->addSeparator();
+//
+//			{
+//				/* AniList */
+//				QMenu* submenu = menu->addMenu(tr("&AniList"));
+//				QAction* action = submenu->addAction(tr("Go to my &profile"));
+//				action = submenu->addAction(tr("Go to my &stats"));
+//			}
+//
+//			{
+//				/* Kitsu */
+//				QMenu* submenu = menu->addMenu(tr("&Kitsu"));
+//				QAction* action = submenu->addAction(tr("Go to my &feed"));
+//				action = submenu->addAction(tr("Go to my &library"));
+//				action = submenu->addAction(tr("Go to my &profile"));
+//			}
+//			{
+//				QMenu* submenu = menu->addMenu(tr("&MyAnimeList"));
+//				QAction* action = submenu->addAction(tr("Go to my p&anel"));
+//				action = submenu->addAction(tr("Go to my &profile"));
+//				action = submenu->addAction(tr("Go to my &history"));
+//			}
+		}
+	}
 
-	action = menu->addAction(tr("&Settings"), [this] {
-		SettingsDialog dialog(this);
-		dialog.exec();
-	});
-	action->setMenuRole(QAction::PreferencesRole);
-
-	menu = menubar->addMenu(tr("&View"));
-
-	std::map<QAction*, int> page_to_index_map = {};
-
-	QActionGroup* pages_group = new QActionGroup(this);
-	pages_group->setExclusive(true);
+	{
+		/* Tools */
+		QMenu* menu = menubar->addMenu(tr("&Tools"));
+//		{
+//			/* Export anime list */
+//			QMenu* submenu = menu->addMenu(tr("&Export anime list"));
+//
+//			{
+//				/* Markdown export */
+//				QAction* action = submenu->addAction(tr("Export as &Markdown..."));
+//			}
+//
+//			{
+//				/* XML export */
+//				QAction* action = submenu->addAction(tr("Export as MyAnimeList &XML..."));
+//			}
+//		}
+//		menu->addSeparator();
+//
+//		{
+//			QAction* action = menu->addAction(tr("Enable anime &recognition"));
+//			action->setCheckable(true);
+//		}
+//
+//		{
+//			QAction* action = menu->addAction(tr("Enable auto &sharing"));
+//			action->setCheckable(true);
+//		}
+//
+//		{
+//			QAction* action = menu->addAction(tr("Enable &auto synchronization"));
+//			action->setCheckable(true);
+//		}
+//
+//		menu->addSeparator();
 
-	action = pages_group->addAction(menu->addAction(tr("&Now Playing")));
-	action->setCheckable(true);
-	page_to_index_map[action] = 0;
+		{
+			QAction* action = menu->addAction(tr("&Settings"), [this] {
+				SettingsDialog dialog(this);
+				dialog.exec();
+			});
+			action->setMenuRole(QAction::PreferencesRole);
+		}
+	}
+
+	{
+		/* View */
+		QMenu* menu = menubar->addMenu(tr("&View"));
 
-	action = pages_group->addAction(menu->addAction(tr("&Anime List")));
-	page_to_index_map[action] = 1;
-	action->setCheckable(true);
-	action->setChecked(true);
+		{
+			/* Pages... */
+			std::map<QAction*, int> page_to_index_map = {};
 
-	action = pages_group->addAction(menu->addAction(tr("&History")));
-	action->setCheckable(true);
-	page_to_index_map[action] = 2;
+			QActionGroup* pages_group = new QActionGroup(this);
+			pages_group->setExclusive(true);
 
-	action = pages_group->addAction(menu->addAction(tr("&Statistics")));
-	action->setCheckable(true);
-	page_to_index_map[action] = 3;
+			{
+				QAction* action = pages_group->addAction(menu->addAction(tr("&Now Playing")));
+				action->setCheckable(true);
+				page_to_index_map[action] = 0;
+			}
 
-	action = pages_group->addAction(menu->addAction(tr("S&earch")));
-	action->setCheckable(true);
-	page_to_index_map[action] = 4;
+			{
+				QAction* action = pages_group->addAction(menu->addAction(tr("&Anime List")));
+				action->setCheckable(true);
+				action->setChecked(true);
+				page_to_index_map[action] = 1;
+			}
+
+			{
+				QAction* action = pages_group->addAction(menu->addAction(tr("&History")));
+				action->setCheckable(true);
+				page_to_index_map[action] = 2;
+			}
 
-	action = pages_group->addAction(menu->addAction(tr("Se&asons")));
-	action->setCheckable(true);
-	page_to_index_map[action] = 5;
+			{
+				QAction* action = pages_group->addAction(menu->addAction(tr("&Statistics")));
+				action->setCheckable(true);
+				page_to_index_map[action] = 3;
+			}
 
-	action = pages_group->addAction(menu->addAction(tr("&Torrents")));
-	action->setCheckable(true);
-	page_to_index_map[action] = 6;
+			{
+				QAction* action = pages_group->addAction(menu->addAction(tr("S&earch")));
+				action->setCheckable(true);
+				page_to_index_map[action] = 4;
+			}
 
-	connect(sidebar, &SideBar::CurrentItemChanged, this,
-	        [pages_group](int index) { pages_group->actions()[index]->setChecked(true); });
+			{
+				QAction* action = pages_group->addAction(menu->addAction(tr("Se&asons")));
+				action->setCheckable(true);
+				page_to_index_map[action] = 5;
+			}
 
-	connect(pages_group, &QActionGroup::triggered, this,
-	        [this, page_to_index_map](QAction* action) { sidebar->SetCurrentItem(page_to_index_map.at(action)); });
+			{
+				QAction* action = pages_group->addAction(menu->addAction(tr("&Torrents")));
+				action->setCheckable(true);
+				page_to_index_map[action] = 6;
+			}
 
-	menu->addSeparator();
-	menu->addAction(tr("Show sidebar"));
+			/* pain in my ass */
+			connect(sidebar.get(), &SideBar::CurrentItemChanged, this,
+			        [pages_group](int index) { pages_group->actions()[index]->setChecked(true); });
+
+			connect(pages_group, &QActionGroup::triggered, this,
+			        [this, page_to_index_map](QAction* action) { sidebar->SetCurrentItem(page_to_index_map.at(action)); });
+		}
+
+		menu->addSeparator();
 
-	menu = menubar->addMenu(tr("&Help"));
-	action = menu->addAction(tr("&About Minori"), this, [this] {
-		AboutWindow dialog(this);
-		dialog.exec();
-	});
-	action = menu->addAction(tr("About &Qt"), qApp, &QApplication::aboutQt);
-	action->setMenuRole(QAction::AboutQtRole);
+//		{
+//			QAction* action = menu->addAction(tr("Show sidebar"));
+//		}
+	}
+
+	{
+		/* Help */
+		QMenu* menu = menubar->addMenu(tr("&Help"));
 
+		{
+			/* About Minori */
+			menu->addAction(tr("&About Minori"), this, [this] {
+				AboutWindow dialog(this);
+				dialog.exec();
+			});
+		}
+
+		{
+			/* About Qt */
+			QAction* action = menu->addAction(tr("About &Qt"), qApp, &QApplication::aboutQt);
+			action->setMenuRole(QAction::AboutQtRole);
+		}
+	}
 	/* QMainWindow will delete the old one for us,
 	   according to the docs */
 	setMenuBar(menubar);
 
 	/* Toolbar */
+
 	/* remove old toolbar(s) */
-	QList<QToolBar*> toolbars = findChildren<QToolBar*>();
-	for (auto& t : toolbars) {
+	for (QToolBar*& t : findChildren<QToolBar*>(Qt::FindDirectChildrenOnly)) {
 		removeToolBar(t);
 		delete t;
 	}
 
-	QToolBar* toolbar = new QToolBar(this);
-	toolbar->addAction(QIcon(":/icons/24x24/arrow-circle-double-135.png"), tr("&Synchronize"),
-	                   [this] { AsyncSynchronize(stack); });
-	toolbar->addSeparator();
+	{
+		/* Toolbar */
+		QToolBar* toolbar = new QToolBar(this);
+		toolbar->addAction(QIcon(":/icons/24x24/arrow-circle-double-135.png"), tr("&Synchronize"),
+		                   [this] { AsyncSynchronize(stack.get()); });
 
-	QToolButton* button = new QToolButton(toolbar);
+		toolbar->addSeparator();
 
-	menu = new QMenu(button);
-	action = menu->addAction(tr("Add new folder..."));
+		{
+			QToolButton* button = new QToolButton(toolbar);
+			{
+				QMenu* menu = new QMenu(button);
+				QAction* action = menu->addAction(tr("..."));
 
-	button->setMenu(menu);
-	button->setIcon(QIcon(":/icons/24x24/folder-open.png"));
-	button->setPopupMode(QToolButton::InstantPopup);
-	toolbar->addWidget(button);
+				button->setMenu(menu);
+			}
+			button->setIcon(QIcon(":/icons/24x24/folder-open.png"));
+			button->setPopupMode(QToolButton::InstantPopup);
+			toolbar->addWidget(button);
+		}
 
-	button = new QToolButton(toolbar);
+		{
+			QToolButton* button = new QToolButton(toolbar);
 
-	menu = new QMenu(button);
-	action = menu->addAction(tr("Placeholder"));
+			{
+				QMenu* menu = new QMenu(button);
+				QAction* action = menu->addAction(tr("..."));
+
+				button->setMenu(menu);
+			}
 
-	button->setMenu(menu);
-	button->setIcon(QIcon(":/icons/24x24/application-export.png"));
-	button->setPopupMode(QToolButton::InstantPopup);
-	toolbar->addWidget(button);
+			button->setIcon(QIcon(":/icons/24x24/application-export.png"));
+			button->setPopupMode(QToolButton::InstantPopup);
+			toolbar->addWidget(button);
+		}
 
-	toolbar->addSeparator();
-	toolbar->addAction(QIcon(":/icons/24x24/gear.png"), tr("S&ettings"), [this] {
-		SettingsDialog dialog(this);
-		dialog.exec();
-	});
-	addToolBar(toolbar);
+		toolbar->addSeparator();
+		toolbar->addAction(QIcon(":/icons/24x24/gear.png"), tr("S&ettings"), [this] {
+			SettingsDialog dialog(this);
+			dialog.exec();
+		});
+		addToolBar(toolbar);
+	}
 }
 
 void MainWindow::SetActivePage(QWidget* page) {
--- a/src/track/media.cc	Mon Nov 06 13:48:11 2023 -0500
+++ b/src/track/media.cc	Tue Nov 07 08:03:42 2023 -0500
@@ -40,10 +40,18 @@
 	ret["language"] = Strings::ToUtf8String(elements.get(anitomy::kElementLanguage));
 	ret["group"] = Strings::ToUtf8String(elements.get(anitomy::kElementReleaseGroup));
 	ret["episode"] = Strings::ToUtf8String(elements.get(anitomy::kElementEpisodeNumber));
+	ret["resolution"] = Strings::ToUtf8String(elements.get(anitomy::kElementVideoResolution));
 
 	return ret;
 }
 
+std::unordered_map<std::string, std::string> GetFileElements(std::string basename) {
+	anitomy::Anitomy anitomy;
+	anitomy.Parse(Strings::ToWstring(basename));
+
+	return GetMapFromElements(anitomy.elements());
+}
+
 std::unordered_map<std::string, std::string> GetFileElements(Filesystem::Path path) {
 	anitomy::Anitomy anitomy;
 	anitomy.Parse(Strings::ToWstring(path.Basename()));