/*
Copyright (C) 2012 Sebastian Herbord. All rights reserved.
This file is part of Mod Organizer.
Mod Organizer is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Mod Organizer is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Mod Organizer. If not, see .
*/
#ifndef DOWNLOADMANAGER_H
#define DOWNLOADMANAGER_H
#include "serverinfo.h"
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
using namespace boost::accumulators;
namespace MOBase
{
class IPluginGame;
}
class NexusInterface;
class PluginContainer;
class OrganizerCore;
/**
* @brief QFileSystemWatcher with a nestable RAII suspension scope.
*
* Forwards directoryChanged() only while no Guard is alive. Use a Guard to
* bracket filesystem writes that would otherwise trigger a spurious refresh.
*/
class DirWatcherManager : public QObject
{
Q_OBJECT
public:
explicit DirWatcherManager(QObject* parent = nullptr);
/// Set the directory being watched (replaces any previous path).
void setPath(const QString& path);
/// True while one or more Guards are alive.
bool isSuspended() const;
/**
* @brief RAII suspension guard. Nests safely; the only way to suspend
* forwarding.
*/
class [[nodiscard]] Guard
{
public:
explicit Guard(DirWatcherManager& manager);
~Guard();
Guard(const Guard&) = delete;
Guard& operator=(const Guard&) = delete;
Guard(Guard&&) = delete;
Guard& operator=(Guard&&) = delete;
private:
DirWatcherManager& m_manager;
};
/// Returns a suspension Guard bound to the caller's scope.
[[nodiscard]] Guard scopedGuard();
signals:
/// Emitted when the watched directory changes and no Guard is active.
void directoryChanged();
private slots:
void onDirectoryChanged(const QString&);
private:
void releaseSuspension();
QFileSystemWatcher m_watcher;
int m_suspendDepth = 0;
};
/*!
* \brief manages downloading of files and provides progress information for gui
*elements
**/
class DownloadManager : public QObject
{
Q_OBJECT
public:
/**
* @brief RAII full-reset guard. Use when the row count changes; drops
* view selection/scroll state. Nests safely: inner guards coalesce into
* the outermost scope so only one reset is emitted.
*/
class [[nodiscard]] ModelResetGuard
{
public:
explicit ModelResetGuard(DownloadManager& manager);
~ModelResetGuard();
ModelResetGuard(const ModelResetGuard&) = delete;
ModelResetGuard& operator=(const ModelResetGuard&) = delete;
ModelResetGuard(ModelResetGuard&&) = delete;
ModelResetGuard& operator=(ModelResetGuard&&) = delete;
private:
DownloadManager& m_manager;
};
enum DownloadState
{
STATE_STARTED = 0,
STATE_DOWNLOADING,
STATE_CANCELING,
STATE_PAUSING,
STATE_CANCELED,
STATE_PAUSED,
STATE_ERROR,
STATE_FETCHINGMODINFO,
STATE_FETCHINGFILEINFO,
STATE_FETCHINGMODINFO_MD5,
STATE_NOFETCH,
STATE_READY,
STATE_INSTALLED,
STATE_UNINSTALLED
};
/**
* @brief Stable identifier for a download.
*
* Monotonically increasing within a session and never reused. Distinct from
* row indices, which are positional and shift as the list is mutated.
*/
using DownloadID = unsigned int;
/**
* @brief A download that has been requested but has not yet produced a
* DownloadInfo.
*
* Created when the user initiates an NXM download; drained either when the
* Nexus API returns the actual download URL (at which point a DownloadInfo
* is created using reservedID as its download id so that external references
* handed out before the download existed remain valid) or when the request
* is cancelled or fails.
*/
struct PendingDownload
{
QString gameName;
int modID;
int fileID;
DownloadID reservedID;
};
private:
struct DownloadInfo
{
~DownloadInfo() { delete m_FileInfo; }
accumulator_set> m_DownloadAcc;
accumulator_set> m_DownloadTimeAcc;
qint64 m_DownloadLast;
qint64 m_DownloadTimeLast;
DownloadID m_DownloadID;
QString m_FileName;
QFile m_Output;
QNetworkReply* m_Reply;
QElapsedTimer m_StartTime;
qint64 m_PreResumeSize;
std::pair m_Progress;
bool m_HasData;
DownloadState m_State;
int m_CurrentUrl;
QStringList m_Urls;
qint64 m_ResumePos;
qint64 m_TotalSize;
QDateTime m_Created; // used as a cache in DownloadManager::getFileTime, may not be
// valid elsewhere
QByteArray m_Hash;
QStringList m_GamesToQuery;
QString m_RemoteFileName;
int m_Tries;
bool m_ReQueried;
bool m_AskIfNotFound;
quint32 m_TaskProgressId;
MOBase::ModRepositoryFileInfo* m_FileInfo{nullptr};
bool m_Hidden;
/**
* @brief Issue a new download id.
*
* The only supported way to obtain one; ids are monotonically increasing
* within a session and never reused.
*/
static DownloadID newDownloadID();
/**
* @brief Create a new DownloadInfo for a fresh download.
*
* When reservedID is provided it is used as the download id. Callers that
* need to hand out an id before the DownloadInfo exists (e.g. the NXM flow
* reserves an id when the request is queued, long before the Nexus API
* returns the actual URL) should reserve via newDownloadID() and pass it
* here. Otherwise a fresh id is drawn internally.
*/
static DownloadInfo* createNew(const MOBase::ModRepositoryFileInfo* fileInfo,
const QStringList& URLs,
std::optional reservedID = {});
static DownloadInfo* createFromMeta(const QString& filePath, bool showHidden,
const QString outputDirectory,
std::optional fileSize = {});
/**
* @brief rename the file
* this will change the file name as well as the display name. It will automatically
* append .unfinished to the name if this file is still being downloaded
* @param newName the new name to setName
* @param renameFile if true, the file is assumed to exist and renamed. If the file
*does not yet exist, set this to false
**/
void setName(QString newName, bool renameFile);
DownloadID downloadID() { return m_DownloadID; }
bool isPausedState();
QString currentURL();
private:
static DownloadID s_NextDownloadID;
private:
DownloadInfo()
: m_TotalSize(0), m_ReQueried(false), m_Hidden(false), m_HasData(false),
m_AskIfNotFound(true), m_DownloadTimeLast(0), m_DownloadLast(0),
m_DownloadAcc(tag::rolling_window::window_size = 200),
m_DownloadTimeAcc(tag::rolling_window::window_size = 200)
{}
};
friend class DownloadManagerProxy;
using SignalDownloadCallback = boost::signals2::signal;
public:
/**
* @brief constructor
*
* @param nexusInterface interface to use to retrieve information from the relevant
*nexus page
* @param parent parent object
**/
explicit DownloadManager(NexusInterface* nexusInterface, QObject* parent);
~DownloadManager();
void setParentWidget(QWidget* w);
/**
* @brief determine if a download is currently in progress
*
* @return true if there is currently a download in progress
**/
bool downloadsInProgress();
/**
* @brief determine if a download is currently in progress, does not count paused
*ones.
*
* @return true if there is currently a download in progress (that is not paused
*already).
**/
bool downloadsInProgressNoPause();
/**
* @brief set the output directory to write to
*
* @param outputDirectory the new output directory
**/
void setOutputDirectory(const QString& outputDirectory, const bool refresh = true);
/**
* @return current download directory
**/
QString getOutputDirectory() const { return m_OutputDirectory; }
/**
* @brief sets whether hidden files are to be shown after all
*/
void setShowHidden(bool showHidden);
void setPluginContainer(PluginContainer* pluginContainer);
/**
* @brief download from an already open network connection
*
* @param reply the network reply to download from
* @param fileInfo information about the file, like mod id, file id, version, ...
* @return true if the download was started, false if it wasn't. The latter currently
*only happens if there is a duplicate and the user decides not to download again
**/
bool addDownload(QNetworkReply* reply, const MOBase::ModRepositoryFileInfo* fileInfo);
/**
* @brief download from an already open network connection
*
* @param reply the network reply to download from
* @param fileName the name to use for the file. This may be overridden by the name in
*the fileInfo-structure or if the http stream specifies a name
* @param fileInfo information previously retrieved from the nexus network
* @return true if the download was started, false if it wasn't. The latter currently
*only happens if there is a duplicate and the user decides not to download again
**/
bool addDownload(QNetworkReply* reply, const QStringList& URLs,
const QString& fileName, QString gameName, int modID, int fileID = 0,
const MOBase::ModRepositoryFileInfo* fileInfo =
new MOBase::ModRepositoryFileInfo(),
std::optional reservedID = {});
/**
* @brief start a download using a nxm-link
*
* Starts a download using a nxm-link. The download manager will first query the
* nexus page for file information. The returned id identifies the eventual
* download; it is reserved immediately so external references remain valid even
* before the Nexus API responds.
* @param url a nxm link looking like this: nxm://skyrim/mods/1234/files/4711
* @return the reserved download id
* @todo the game name encoded into the link is currently ignored, all downloads are
*incorrectly assumed to be for the identified game
**/
DownloadID addNXMDownload(const QString& url);
/**
* @brief retrieve the total number of downloads, both finished and unfinished
*including downloads from previous sessions
*
* @return total number of downloads
**/
int numTotalDownloads() const;
/**
* @brief retrieve number of pending downloads (nexus downloads for which we don't
* know the name and url yet)
* @return number of pending downloads
*/
int numPendingDownloads() const;
/**
* @brief retrieve the info of a pending download
* @param index index of the pending download (index in the range [0,
* numPendingDownloads()[)
* @return the PendingDownload entry at the given index
*/
PendingDownload getPendingDownload(int index);
/**
* @brief Resolve a view row to a stable DownloadID.
*
* Rows cover active downloads followed by pending ones. Returns 0 if the row
* is out of range.
*/
DownloadID downloadIDAtRow(int row) const;
/**
* @brief Resolve a stable DownloadID to its current view row.
*
* @return the current row, or -1 if no download with that id is tracked.
*/
int rowForDownloadID(DownloadID id) const;
/**
* @brief retrieve the full path to the download specified by index
*
* @param index the index to look up
* @return absolute path of the file
**/
QString getFilePath(int index) const;
/**
* @brief retrieve a descriptive name of the download specified by index
*
* @param index index of the file to look up
* @return display name of the file
**/
QString getDisplayName(int index) const;
/**
* @brief retrieve the filename of the download specified by index
*
* @param index index of the file to look up
* @return name of the file
**/
QString getFileName(int index) const;
/**
* @brief retrieve the file size of the download specified by index
*
* @param index index of the file to look up
* @return size of the file (total size during download)
*/
qint64 getFileSize(int index) const;
/**
* @brief retrieve the creation time of the download specified by index
* @param index index of the file to look up
* @return size of the file (total size during download)
*/
QDateTime getFileTime(int index) const;
/**
* @brief retrieve the current progress of the download specified by index
*
* @param index index of the file to look up
* @return progress of the download in percent (integer)
**/
std::pair getProgress(int index) const;
/**
* @brief retrieve the current state of the download
*
* retrieve the current state of the download. A download usually goes through
* the following states:
* started -> downloading -> fetching mod info -> fetching file info -> done
* in case of downloads started via nxm-link, file information is fetched first
*
* @param index index of the file to look up
* @return the download state
**/
DownloadState getState(int index) const;
/**
* @param index index of the file to look up
* @return true if the nexus information for this download is not complete
**/
bool isInfoIncomplete(int index) const;
/**
* @brief retrieve the nexus mod id of the download specified by index
*
* @param index index of the file to look up
* @return the nexus mod id
**/
int getModID(int index) const;
/**
* @brief retrieve the displayable game name of the download specified by the index
*
* @param index index of the file to look up
* @return the displayable game name
**/
QString getDisplayGameName(int index) const;
/**
* @brief retrieve the game name of the downlaod specified by the index
*
* @param index index of the file to look up
* @return the game name
**/
QString getGameName(int index) const;
/**
* @brief determine if the specified file is supposed to be hidden
* @param index index of the file to look up
* @return true if the specified file is supposed to be hidden
*/
bool isHidden(int index) const;
/**
* @brief retrieve all nexus info of the download specified by index
*
* @param index index of the file to look up
* @return the nexus mod information
**/
const MOBase::ModRepositoryFileInfo* getFileInfo(int index) const;
/**
* @brief mark a download as installed
*
* @param index index of the file to mark installed
*/
void markInstalled(int index);
void markInstalled(QString download);
/**
* @brief mark a download as uninstalled
*
* @param index index of the file to mark uninstalled
*/
void markUninstalled(int index);
void markUninstalled(QString download);
/**
* @brief refreshes the list of downloads
*/
void refreshList();
/**
* @brief Query infos for every download in the list
*/
void queryDownloadListInfo();
/**
* @return the directory watcher for the downloads folder; call
* scopedGuard() on it to suspend across filesystem writes.
*/
DirWatcherManager& dirWatcher() { return m_DirWatcher; }
public: // IDownloadManager interface:
int startDownloadURLs(const QStringList& urls);
int startDownloadURLWithMeta(const QString& url, const QString& game,
const QString& name, const QString& modName,
const QString& version, const QString& source);
int startDownloadNexusFile(const QString& gameName, int modID, int fileID);
QString downloadPath(int id);
boost::signals2::connection
onDownloadComplete(const std::function& callback);
boost::signals2::connection
onDownloadPaused(const std::function& callback);
boost::signals2::connection
onDownloadFailed(const std::function& callback);
boost::signals2::connection
onDownloadRemoved(const std::function& callback);
/**
* @brief retrieve a download index from the filename
* @param fileName file to look up
* @return index of that download or -1 if it wasn't found
*/
int indexByName(const QString& fileName) const;
int indexByInfo(const DownloadInfo* info) const;
void pauseAll();
/**
* @brief notify the UI that a single row's data changed. Preserves view
* state; prefer over ModelResetGuard when the row count is unchanged.
*
* @param row the row that changed. This corresponds to the download index
*/
void notifyRowChanged(int row);
Q_SIGNALS:
/**
* @brief emitted before the download list model is about to be reset
*
* Emitted by ModelResetGuard on construction. Views should call
* beginResetModel() in response.
*/
void aboutToResetModel();
/**
* @brief emitted after the download list model has been reset
*
* Emitted by ModelResetGuard on destruction. Views should call
* endResetModel() in response.
*/
void modelReset();
/**
* @brief signals that the specified download row's data has changed
*
* @param row the row that changed. This corresponds to the download index
*/
void rowChanged(int row);
/**
* @brief signals the ui that a message should be displayed
*
* @param message the message to display
**/
void showMessage(const QString& message);
/**
* @brief emitted whenever the state of a download changes
* @param row the row that changed
* @param state the new state
*/
void stateChanged(int row, DownloadManager::DownloadState state);
/**
* @brief emitted whenever a download completes successfully, reporting the download
* speed for the server used
*/
void downloadSpeed(const QString& serverName, int bytesPerSecond);
/**
* @brief emitted whenever a new download is added to the list
*/
void downloadAdded();
public slots:
/**
* @brief removes the specified download
*
* @param index index of the download to remove
* @param deleteFile if true, the file will also be deleted from disc, otherwise it is
*only marked as hidden.
**/
void removeDownload(int index, bool deleteFile);
/**
* @brief restores the specified download to view (which was previously hidden
* @param index index of the download to restore
*/
void restoreDownload(int index);
/**
* @brief cancel the specified download. This will lead to the corresponding file to
*be deleted
*
* @param id id of the download to cancel
**/
void cancelDownload(DownloadID id);
void pauseDownload(DownloadID id);
void resumeDownload(DownloadID id);
void queryInfo(int index);
void queryInfoMd5(int index, bool askIfNotFound = true);
void visitOnNexus(int index);
void visitUploaderProfile(int index);
void openFile(int index);
void openMetaFile(int index);
void openInDownloadsFolder(int index);
void nxmDescriptionAvailable(QString gameName, int modID, QVariant userData,
QVariant resultData, int requestID);
void nxmFilesAvailable(QString gameName, int modID, QVariant userData,
QVariant resultData, int requestID);
void nxmFileInfoAvailable(QString gameName, int modID, int fileID, QVariant userData,
QVariant resultData, int requestID);
void nxmDownloadURLsAvailable(QString gameName, int modID, int fileID,
QVariant userData, QVariant resultData, int requestID);
void nxmFileInfoFromMd5Available(QString gameName, QVariant userData,
QVariant resultData, int requestID);
void nxmRequestFailed(QString gameName, int modID, int fileID, QVariant userData,
int requestID, int errorCode, const QString& errorString);
void managedGameChanged(MOBase::IPluginGame const* gamePlugin);
private slots:
void downloadProgress(qint64 bytesReceived, qint64 bytesTotal);
void downloadReadyRead();
/**
* @brief Slot wired to QNetworkReply::finished().
*
* Resolves the originating reply through sender() and then dispatches to
* finishDownload. Use the public finishDownload directly for non-slot calls.
*/
void onReplyFinished();
/**
* @brief Run the post-download bookkeeping for the given download.
*
* Writes any remaining data, transitions the download's state, and emits
* the appropriate plugin signals.
*/
void finishDownload(DownloadID id);
void downloadError(QNetworkReply::NetworkError error);
void metaDataChanged();
private:
void createMetaFile(DownloadInfo* info);
DownloadManager::DownloadInfo* getDownloadInfo(QString fileName);
public:
/** Get a unique filename for a download.
*
* This allows you multiple versions of download files, useful if the file
* comes from a web site with no version control
*
* @param basename: Name of the file
*
* @return Unique(ish) name
*/
QString getDownloadFileName(const QString& baseName, bool rename = false) const;
private:
/**
* @brief Begin downloading into newDownload from reply.
*
* On the !resume path newDownload becomes owned by m_ActiveDownloads on
* success; on failure (e.g. the output file cannot be opened) it is deleted
* before returning. Returns whether the download actually started.
*/
bool startDownload(QNetworkReply* reply, DownloadInfo* newDownload, bool resume);
void resumeDownloadInt(DownloadID id);
/**
* @brief start a download from a url
*
* @param url the url to download from
* @param fileInfo information previously retrieved from the mod page
* @return true if the download was started, false if it wasn't. The latter currently
*only happens if there is a duplicate and the user decides not to download again
**/
bool addDownload(const QStringList& URLs, QString gameName, int modID, int fileID,
const MOBase::ModRepositoryFileInfo* fileInfo,
std::optional reservedID = {});
// important: the caller has to lock the list-mutex, otherwise the
// DownloadInfo-pointer might get invalidated at any time
DownloadInfo* findDownload(QObject* reply, int* index = nullptr) const;
void removeFile(int index, bool deleteFile);
QString getFileNameFromNetworkReply(QNetworkReply* reply);
void setState(DownloadInfo* info, DownloadManager::DownloadState state);
DownloadInfo* downloadInfoByID(DownloadID id);
QString displayNameByInfo(const DownloadInfo* info) const;
void removePending(QString gameName, int modID, int fileID);
/**
* @brief Fire onDownloadFailed for a pending entry, if any matches.
*
* Used on Nexus API failures so callers holding a reserved id from
* addNXMDownload do not wait indefinitely for a result. No-op if no pending
* entry matches the (gameName, modID, fileID) triple.
*/
void notifyPendingDownloadFailed(const QString& gameName, int modID, int fileID);
/**
* @brief Roll back a download that has not yet been activated.
*
* Ensures a caller awaiting the reservedID receives an onDownloadFailed
* callback. Must not be called once the download has been registered as
* active.
*/
void cancelPendingDownload(DownloadInfo* newDownload, QNetworkReply* reply);
static QString getFileTypeString(int fileType);
void writeData(DownloadInfo* info);
QString getValidGameShortName(const QString& gameNexusName) const;
private:
static const int AUTOMATIC_RETRIES = 3;
private:
NexusInterface* m_NexusInterface;
OrganizerCore* m_OrganizerCore;
QWidget* m_ParentWidget;
QVector m_PendingDownloads;
QVector m_ActiveDownloads;
// Secondary index into m_ActiveDownloads keyed by m_DownloadID; kept in sync
// with every m_ActiveDownloads mutation.
QHash m_ByID;
QString m_OutputDirectory;
std::set m_RequestIDs;
DirWatcherManager m_DirWatcher;
// nesting depth of active ModelResetGuard scopes; see its docs
int m_modelResetDepth = 0;
SignalDownloadCallback m_DownloadComplete;
SignalDownloadCallback m_DownloadPaused;
SignalDownloadCallback m_DownloadFailed;
SignalDownloadCallback m_DownloadRemoved;
bool m_ShowHidden;
MOBase::IPluginGame const* m_ManagedGame;
};
#endif // DOWNLOADMANAGER_H