view src/gui/pages/torrents.cc @ 337:a7d4e5107531

dep/animone: REFACTOR ALL THE THINGS 1: animone now has its own syntax divergent from anisthesia, making different platforms actually have their own sections 2: process names in animone are now called `comm' (this will probably break things). this is what its called in bsd/linux so I'm just going to use it everywhere 3: the X11 code now checks for the existence of a UTF-8 window title and passes it if available 4: ANYTHING THATS NOT LINUX IS 100% UNTESTED AND CAN AND WILL BREAK! I still actually need to test the bsd code. to be honest I'm probably going to move all of the bsds into separate files because they're all essentially different operating systems at this point
author Paper <paper@paper.us.eu.org>
date Wed, 19 Jun 2024 12:51:15 -0400
parents 9a88e1725fd2
children a0aa8c8c4307
line wrap: on
line source

#include "gui/pages/torrents.h"
#include "core/filesystem.h"
#include "core/http.h"
#include "core/session.h"
#include "core/strings.h"
#include "gui/widgets/text.h"
#include "track/media.h"

#include <QByteArray>
#include <QDataStream>
#include <QDebug>
#include <QHeaderView>
#include <QThread>
#include <QToolBar>
#include <QTreeView>
#include <QVBoxLayout>
#include <QtGlobal>

#include <algorithm>
#include <fstream>
#include <iostream>
#include <sstream>

#include "anitomy/anitomy.h"
#include "pugixml.hpp"

/* This file is very, very similar to the anime list page.
 *
 * It differs from Taiga in that it uses tabs instead of
 * those "groups", but those are custom painted and a pain in the ass to
 * maintain over multiple platforms.
 */

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) {
}

void TorrentsPageListModel::DownloadTorrents(QItemSelection selection) {
	const auto indexes = selection.indexes();

	for (const auto& index : indexes) {
		/* a torrent file IS literally text... */
		const std::string link = list.at(index.row()).GetLink();
		const std::string filename = list.at(index.row()).GetFilename() + ".torrent";

		const std::filesystem::path torrents_dir = Filesystem::GetTorrentsPath();
		std::filesystem::create_directories(torrents_dir);

		/* this sucks */
		HTTP::RequestThread* thread = new HTTP::RequestThread(link, {}, "", HTTP::Type::Get, this);

		connect(thread, &HTTP::RequestThread::ReceivedData, this, [this, torrents_dir, filename](const QByteArray& data) {
			std::ofstream file(torrents_dir / filename, std::ofstream::out | std::ofstream::trunc);
			if (!file)
				return; // wat

			file.write(data.data(), data.size());
			file.close();
		});
		connect(thread, &HTTP::RequestThread::finished, thread, &HTTP::RequestThread::deleteLater);

		thread->start();
	}
}

QByteArray TorrentsPageListModel::DownloadTorrentList() {
	return HTTP::Request(session.config.torrents.feed_link);
}

void TorrentsPageListModel::ParseFeedDescription(const std::string& description, Torrent& torrent) {
	/* Parse description... */
	enum class Keys {
		SIZE,
		AUTHORIZED,
		SUBMITTER,
		COMMENT
	};

	const std::unordered_map<std::string, Keys> KeyMap = {
	    {"Size",       Keys::SIZE      },
	    {"Authorized", Keys::AUTHORIZED},
	    {"Submitter",  Keys::SUBMITTER },
	    {"Comment",    Keys::COMMENT   }
    };

	/* Parse size from description */
	std::istringstream descstream(description);

	for (std::string line; std::getline(descstream, line);) {
		const size_t pos = line.find_first_of(':', 0);
		if (pos == std::string::npos)
			continue;

		const std::string key = line.substr(0, pos);
		const std::string value = line.substr(line.find_first_not_of(": ", pos));

		switch (KeyMap.at(key)) {
			case Keys::COMMENT: torrent.SetDescription(value); break;
			case Keys::SIZE: torrent.SetSize(Strings::HumanReadableSizeToBytes(value)); break;
			case Keys::AUTHORIZED:
				if (torrent.GetGroup().empty() && value != "N/A")
					torrent.SetGroup(value);
				break;
			default: break;
		}
	}
}

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 */
		{
			anitomy::Anitomy anitomy;
			anitomy.Parse(Strings::ToWstring(torrent.GetFilename()));

			const auto& elements = anitomy.elements();

			/* todo: patch Anitomy so that it doesn't use wide strings */
			torrent.SetTitle(Strings::ToUtf8String(elements.get(anitomy::kElementAnimeTitle)));
			std::string episode = Strings::ToUtf8String(elements.get(anitomy::kElementEpisodeNumber));
			Strings::RemoveLeadingChars(episode, '0');
			torrent.SetEpisode(episode);
			torrent.SetGroup(Strings::ToUtf8String(elements.get(anitomy::kElementReleaseGroup)));
			torrent.SetResolution(Strings::ToUtf8String(elements.get(anitomy::kElementVideoResolution)));
		}

		std::string description = item.child_value("description");
		Strings::TextifySynopsis(description);
		ParseFeedDescription(description, torrent);

		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"); /* "Video" in Taiga */
				case TL_SEEDERS: return tr("S");
				case TL_LEECHERS: return tr("L");
				case TL_DOWNLOADS: return tr("D");
				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_DOWNLOADS:
				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());

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

	const TorrentModelItem& item = list.at(index.row());

	switch (role) {
		case Qt::DisplayRole:
			switch (index.column()) {
				case TL_TITLE: return Strings::ToQString(item.GetTitle());
				case TL_EPISODE: return Strings::ToQString(item.GetEpisode());
				case TL_GROUP: return Strings::ToQString(item.GetGroup());
				case TL_SIZE: return Strings::ToQString(Strings::BytesToHumanReadableSize(item.GetSize()));
				case TL_RESOLUTION: return Strings::ToQString(item.GetResolution());
				case TL_SEEDERS: return item.GetSeeders();
				case TL_LEECHERS: return item.GetLeechers();
				case TL_DOWNLOADS: return item.GetDownloads();
				case TL_DESCRIPTION: return Strings::ToQString(item.GetDescription());
				case TL_FILENAME: return Strings::ToQString(item.GetFilename());
				case TL_RELEASEDATE: return item.GetDate();
				default: return {};
			}
			break;
		case Qt::UserRole:
			switch (index.column()) {
				case TL_EPISODE: return Strings::ToInt(item.GetEpisode(), -1);
				/* We have to use this to work around some stupid
				 * "conversion ambiguous" error on Linux
				 */
				case TL_SIZE: return QVariant::fromValue(item.GetSize());
				default: return data(index, Qt::DisplayRole);
			}
			break;
		case Qt::SizeHintRole: {
			switch (index.column()) {
				default: {
					/* max horizontal size of 100, height size = size of current font */
					const QString d = data(index, Qt::DisplayRole).toString();
					const QFontMetrics metric = QFontMetrics(QFont());

					return QSize(std::max(metric.boundingRect(d).width(), 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_DOWNLOADS:
				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;

	return Qt::ItemIsEnabled | Qt::ItemIsSelectable;
}

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 */
			toolbar->addAction(QIcon(":/icons/16x16/arrow-circle-315.png"), tr("&Check new torrents"),
			                   [this] { Refresh(); });
		}

		toolbar->addSeparator();

		{
			toolbar->addAction(QIcon(":/icons/16x16/navigation-270-button.png"), tr("Download &marked torrents"),
			                   [this] { DownloadSelection(); });
		}

		{ toolbar->addAction(QIcon(":/icons/16x16/cross-button.png"), tr("&Discard all")); }

		toolbar->addSeparator();

		{ 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);
	}

	{
		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);
		}

		// set column sizes
		treeview->setColumnWidth(TorrentsPageListModel::TL_TITLE, 240);
		treeview->setColumnWidth(TorrentsPageListModel::TL_EPISODE, 60);
		treeview->setColumnWidth(TorrentsPageListModel::TL_GROUP, 100);
		treeview->setColumnWidth(TorrentsPageListModel::TL_SIZE, 70);
		treeview->setColumnWidth(TorrentsPageListModel::TL_RESOLUTION, 100);
		treeview->setColumnWidth(TorrentsPageListModel::TL_SEEDERS, 20);
		treeview->setColumnWidth(TorrentsPageListModel::TL_LEECHERS, 20);
		treeview->setColumnWidth(TorrentsPageListModel::TL_DOWNLOADS, 20);
		treeview->setColumnWidth(TorrentsPageListModel::TL_DESCRIPTION, 200);
		treeview->setColumnWidth(TorrentsPageListModel::TL_FILENAME, 200);
		treeview->setColumnWidth(TorrentsPageListModel::TL_RELEASEDATE, 190);

		treeview->header()->setStretchLastSection(false);

		layout->addWidget(treeview);
	}
}

void TorrentsPage::DownloadSelection() {
	if (!model)
		return;

	const QItemSelection selection = sort_model->mapSelectionToSource(treeview->selectionModel()->selection());

	model->DownloadTorrents(selection);
}

void TorrentsPage::Refresh() {
	if (!model)
		return;

	HTTP::RequestThread* thread = new HTTP::RequestThread(session.config.torrents.feed_link);

	connect(thread, &HTTP::RequestThread::ReceivedData, this, [&](const QByteArray& ba) {
		/* This is to make sure we aren't in a different thread
		 * messing around with GUI stuff
		 */
		treeview->setUpdatesEnabled(false);
		model->ParseTorrentList(ba);
		treeview->setUpdatesEnabled(true);
	});
	connect(thread, &HTTP::RequestThread::finished, thread, &HTTP::RequestThread::deleteLater);

	thread->start();
}