diff --git a/pcsx2-qt/CMakeLists.txt b/pcsx2-qt/CMakeLists.txt index 639ae4a4a8..039be09df7 100644 --- a/pcsx2-qt/CMakeLists.txt +++ b/pcsx2-qt/CMakeLists.txt @@ -17,6 +17,9 @@ target_sources(pcsx2-qt PRIVATE AutoUpdaterDialog.cpp AutoUpdaterDialog.h AutoUpdaterDialog.ui + CoverDownloadDialog.cpp + CoverDownloadDialog.h + CoverDownloadDialog.ui DisplayWidget.cpp DisplayWidget.h EarlyHardwareCheck.cpp @@ -28,6 +31,8 @@ target_sources(pcsx2-qt PRIVATE QtHost.cpp QtHost.h QtKeyCodes.cpp + QtProgressCallback.cpp + QtProgressCallback.h QtUtils.cpp QtUtils.h SettingWidgetBinder.h diff --git a/pcsx2-qt/CoverDownloadDialog.cpp b/pcsx2-qt/CoverDownloadDialog.cpp new file mode 100644 index 0000000000..1b7e3ecda1 --- /dev/null +++ b/pcsx2-qt/CoverDownloadDialog.cpp @@ -0,0 +1,139 @@ +/* PCSX2 - PS2 Emulator for PCs + * Copyright (C) 2002-2022 PCSX2 Dev Team + * + * PCSX2 is free software: you can redistribute it and/or modify it under the terms + * of the GNU Lesser General Public License as published by the Free Software Found- + * ation, either version 3 of the License, or (at your option) any later version. + * + * PCSX2 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 PCSX2. + * If not, see . + */ + +#include "PrecompiledHeader.h" + +#include "common/Assertions.h" + +#include "pcsx2/Frontend/GameList.h" + +#include "CoverDownloadDialog.h" + +CoverDownloadDialog::CoverDownloadDialog(QWidget* parent /*= nullptr*/) + : QDialog(parent) +{ + m_ui.setupUi(this); + m_ui.coverIcon->setPixmap(QIcon::fromTheme("image-fill").pixmap(32)); + updateEnabled(); + + connect(m_ui.start, &QPushButton::clicked, this, &CoverDownloadDialog::onStartClicked); + connect(m_ui.close, &QPushButton::clicked, this, &CoverDownloadDialog::onCloseClicked); + connect(m_ui.urls, &QTextEdit::textChanged, this, &CoverDownloadDialog::updateEnabled); +} + +CoverDownloadDialog::~CoverDownloadDialog() +{ + pxAssert(!m_thread); +} + +void CoverDownloadDialog::closeEvent(QCloseEvent* ev) +{ + cancelThread(); +} + +void CoverDownloadDialog::onDownloadStatus(const QString& text) +{ + m_ui.status->setText(text); +} + +void CoverDownloadDialog::onDownloadProgress(int value, int range) +{ + // Limit to once every five seconds, otherwise it's way too flickery. + // Ideally in the future we'd have some way to invalidate only a single cover. + if (m_last_refresh_time.GetTimeSeconds() >= 5.0f) + { + emit coverRefreshRequested(); + m_last_refresh_time.Reset(); + } + + if (range != m_ui.progress->maximum()) + m_ui.progress->setMaximum(range); + m_ui.progress->setValue(value); +} + +void CoverDownloadDialog::onDownloadComplete() +{ + emit coverRefreshRequested(); + + if (m_thread) + { + m_thread->join(); + m_thread.reset(); + } + + updateEnabled(); + + m_ui.status->setText(tr("Download complete.")); +} + +void CoverDownloadDialog::onStartClicked() +{ + if (m_thread) + cancelThread(); + else + startThread(); +} + +void CoverDownloadDialog::onCloseClicked() +{ + if (m_thread) + cancelThread(); + + done(0); +} + +void CoverDownloadDialog::updateEnabled() +{ + const bool running = static_cast(m_thread); + m_ui.start->setText(running ? tr("Stop") : tr("Start")); + m_ui.start->setEnabled(running || !m_ui.urls->toPlainText().isEmpty()); + m_ui.close->setEnabled(!running); + m_ui.urls->setEnabled(!running); +} + +void CoverDownloadDialog::startThread() +{ + m_thread = std::make_unique(this, m_ui.urls->toPlainText(), m_ui.useSerialFileNames->isChecked()); + m_last_refresh_time.Reset(); + connect(m_thread.get(), &CoverDownloadThread::statusUpdated, this, &CoverDownloadDialog::onDownloadStatus); + connect(m_thread.get(), &CoverDownloadThread::progressUpdated, this, &CoverDownloadDialog::onDownloadProgress); + connect(m_thread.get(), &CoverDownloadThread::threadFinished, this, &CoverDownloadDialog::onDownloadComplete); + m_thread->start(); + updateEnabled(); +} + +void CoverDownloadDialog::cancelThread() +{ + if (!m_thread) + return; + + m_thread->requestInterruption(); + m_thread->join(); + m_thread.reset(); +} + +CoverDownloadDialog::CoverDownloadThread::CoverDownloadThread(QWidget* parent, const QString& urls, bool use_serials) + : QtAsyncProgressThread(parent), m_use_serials(use_serials) +{ + for (const QString& str : urls.split(QChar('\n'))) + m_urls.push_back(str.toStdString()); +} + +CoverDownloadDialog::CoverDownloadThread::~CoverDownloadThread() = default; + +void CoverDownloadDialog::CoverDownloadThread::runAsync() +{ + GameList::DownloadCovers(m_urls, m_use_serials, this); +} diff --git a/pcsx2-qt/CoverDownloadDialog.h b/pcsx2-qt/CoverDownloadDialog.h new file mode 100644 index 0000000000..2fea110f4a --- /dev/null +++ b/pcsx2-qt/CoverDownloadDialog.h @@ -0,0 +1,69 @@ +/* PCSX2 - PS2 Emulator for PCs + * Copyright (C) 2002-2022 PCSX2 Dev Team + * + * PCSX2 is free software: you can redistribute it and/or modify it under the terms + * of the GNU Lesser General Public License as published by the Free Software Found- + * ation, either version 3 of the License, or (at your option) any later version. + * + * PCSX2 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 PCSX2. + * If not, see . + */ + +#pragma once +#include "common/Timer.h" +#include "common/Pcsx2Defs.h" +#include "QtProgressCallback.h" +#include "ui_CoverDownloadDialog.h" +#include +#include +#include +#include + +class CoverDownloadDialog final : public QDialog +{ + Q_OBJECT + +public: + CoverDownloadDialog(QWidget* parent = nullptr); + ~CoverDownloadDialog(); + +Q_SIGNALS: + void coverRefreshRequested(); + +protected: + void closeEvent(QCloseEvent* ev); + +private Q_SLOTS: + void onDownloadStatus(const QString& text); + void onDownloadProgress(int value, int range); + void onDownloadComplete(); + void onStartClicked(); + void onCloseClicked(); + void updateEnabled(); + +private: + class CoverDownloadThread : public QtAsyncProgressThread + { + public: + CoverDownloadThread(QWidget* parent, const QString& urls, bool use_serials); + ~CoverDownloadThread(); + + protected: + void runAsync() override; + + private: + std::vector m_urls; + bool m_use_serials; + }; + + void startThread(); + void cancelThread(); + + Ui::CoverDownloadDialog m_ui; + std::unique_ptr m_thread; + Common::Timer m_last_refresh_time; +}; diff --git a/pcsx2-qt/CoverDownloadDialog.ui b/pcsx2-qt/CoverDownloadDialog.ui new file mode 100644 index 0000000000..4396eb297b --- /dev/null +++ b/pcsx2-qt/CoverDownloadDialog.ui @@ -0,0 +1,117 @@ + + + CoverDownloadDialog + + + + 0 + 0 + 720 + 380 + + + + Download Covers + + + + + + 10 + + + + + + + + :/icons/black/svg/image-fill.svg + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + + + + + PCSX2 can automatically download covers for games which do not currently have a cover set. We do not host any cover images, the user must provide their own source for images. + + + true + + + + + + + + + <html><head/><body><p>In the box below, specify the URLs to download covers from, with one template URL per line. The following variables are available:</p><p><span style=" font-style:italic;">${title}:</span> Title of the game.<br/><span style=" font-style:italic;">${filetitle}:</span> Name component of the game's filename.<br/><span style=" font-style:italic;">${serial}:</span> Serial of the game.</p><p><span style=" font-weight:700;">Example:</span> https://www.example-not-a-real-domain.com/covers/${serial}.jpg</p></body></html> + + + true + + + + + + + + + + By default, the downloaded covers will be saved with the game's title. If this is not desired, you can check the "Use Serial File Names" box below. Using serials instead of game titles will prevent conflicts when multiple regions of the same game are used. + + + true + + + + + + + Use Serial File Names + + + + + + + Waiting to start... + + + + + + + + + + + + false + + + Start + + + true + + + + + + + Close + + + + + + + + + + + + diff --git a/pcsx2-qt/DisplayWidget.cpp b/pcsx2-qt/DisplayWidget.cpp index f634232be1..2f85d3934d 100644 --- a/pcsx2-qt/DisplayWidget.cpp +++ b/pcsx2-qt/DisplayWidget.cpp @@ -247,7 +247,10 @@ bool DisplayWidget::event(QEvent* event) // Forward text input to imgui. if (ImGuiManager::WantsTextInput() && key_event->type() == QEvent::KeyPress) { - const QString text(key_event->text()); + // Don't forward backspace characters. We send the backspace as a normal key event, + // so if we send the character too, it double-deletes. + QString text(key_event->text()); + text.remove(QChar('\b')); if (!text.isEmpty()) ImGuiManager::AddTextInput(text.toStdString()); } diff --git a/pcsx2-qt/MainWindow.cpp b/pcsx2-qt/MainWindow.cpp index 01bcdf655e..8ada30b9b3 100644 --- a/pcsx2-qt/MainWindow.cpp +++ b/pcsx2-qt/MainWindow.cpp @@ -40,6 +40,7 @@ #include "AboutDialog.h" #include "AutoUpdaterDialog.h" +#include "CoverDownloadDialog.h" #include "DisplayWidget.h" #include "GameList/GameListRefreshThread.h" #include "GameList/GameListWidget.h" @@ -291,6 +292,7 @@ void MainWindow::connectSignals() connect(m_ui.actionAbout, &QAction::triggered, this, &MainWindow::onAboutActionTriggered); connect(m_ui.actionCheckForUpdates, &QAction::triggered, this, &MainWindow::onCheckForUpdatesActionTriggered); connect(m_ui.actionOpenDataDirectory, &QAction::triggered, this, &MainWindow::onToolsOpenDataDirectoryTriggered); + connect(m_ui.actionCoverDownloader, &QAction::triggered, this, &MainWindow::onToolsCoverDownloaderTriggered); connect(m_ui.actionGridViewShowTitles, &QAction::triggered, m_game_list_widget, &GameListWidget::setShowCoverTitles); connect(m_ui.actionGridViewZoomIn, &QAction::triggered, m_game_list_widget, [this]() { if (isShowingGameList()) @@ -1495,6 +1497,13 @@ void MainWindow::onToolsOpenDataDirectoryTriggered() QtUtils::OpenURL(this, QUrl::fromLocalFile(path)); } +void MainWindow::onToolsCoverDownloaderTriggered() +{ + CoverDownloadDialog dlg(this); + connect(&dlg, &CoverDownloadDialog::coverRefreshRequested, m_game_list_widget, &GameListWidget::refreshGridCovers); + dlg.exec(); +} + void MainWindow::updateTheme() { updateApplicationTheme(); diff --git a/pcsx2-qt/MainWindow.h b/pcsx2-qt/MainWindow.h index 86abe09052..00b8bce6c3 100644 --- a/pcsx2-qt/MainWindow.h +++ b/pcsx2-qt/MainWindow.h @@ -148,6 +148,7 @@ private Q_SLOTS: void onAboutActionTriggered(); void onCheckForUpdatesActionTriggered(); void onToolsOpenDataDirectoryTriggered(); + void onToolsCoverDownloaderTriggered(); void updateTheme(); void onScreenshotActionTriggered(); void onSaveGSDumpActionTriggered(); diff --git a/pcsx2-qt/MainWindow.ui b/pcsx2-qt/MainWindow.ui index 227f90a089..f6badcdc43 100644 --- a/pcsx2-qt/MainWindow.ui +++ b/pcsx2-qt/MainWindow.ui @@ -192,6 +192,7 @@ + @@ -849,6 +850,11 @@ Big Picture + + + Cover Downloader... + + diff --git a/pcsx2-qt/QtProgressCallback.cpp b/pcsx2-qt/QtProgressCallback.cpp new file mode 100644 index 0000000000..4455446505 --- /dev/null +++ b/pcsx2-qt/QtProgressCallback.cpp @@ -0,0 +1,247 @@ +/* PCSX2 - PS2 Emulator for PCs + * Copyright (C) 2002-2022 PCSX2 Dev Team + * + * PCSX2 is free software: you can redistribute it and/or modify it under the terms + * of the GNU Lesser General Public License as published by the Free Software Found- + * ation, either version 3 of the License, or (at your option) any later version. + * + * PCSX2 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 PCSX2. + * If not, see . + */ + +#include "PrecompiledHeader.h" + +#include "common/Assertions.h" + +#include "QtProgressCallback.h" + +#include +#include +#include +#include + +QtModalProgressCallback::QtModalProgressCallback(QWidget* parent_widget, float show_delay) + : QObject(parent_widget) + , m_dialog(QString(), QString(), 0, 1, parent_widget) + , m_show_delay(show_delay) +{ + m_dialog.setWindowTitle(tr("PCSX2")); + m_dialog.setMinimumSize(QSize(500, 0)); + m_dialog.setModal(parent_widget != nullptr); + m_dialog.setAutoClose(false); + m_dialog.setAutoReset(false); + checkForDelayedShow(); +} + +QtModalProgressCallback::~QtModalProgressCallback() = default; + +bool QtModalProgressCallback::IsCancelled() const +{ + return m_dialog.wasCanceled(); +} + +void QtModalProgressCallback::SetCancellable(bool cancellable) +{ + if (m_cancellable == cancellable) + return; + + BaseProgressCallback::SetCancellable(cancellable); + m_dialog.setCancelButtonText(cancellable ? tr("Cancel") : QString()); +} + +void QtModalProgressCallback::SetTitle(const char* title) +{ + m_dialog.setWindowTitle(QString::fromUtf8(title)); +} + +void QtModalProgressCallback::SetStatusText(const char* text) +{ + BaseProgressCallback::SetStatusText(text); + checkForDelayedShow(); + + if (m_dialog.isVisible()) + m_dialog.setLabelText(QString::fromUtf8(text)); +} + +void QtModalProgressCallback::SetProgressRange(u32 range) +{ + BaseProgressCallback::SetProgressRange(range); + checkForDelayedShow(); + + if (m_dialog.isVisible()) + m_dialog.setRange(0, m_progress_range); +} + +void QtModalProgressCallback::SetProgressValue(u32 value) +{ + BaseProgressCallback::SetProgressValue(value); + checkForDelayedShow(); + + if (m_dialog.isVisible() && static_cast(m_dialog.value()) != m_progress_range) + m_dialog.setValue(m_progress_value); + + QCoreApplication::processEvents(); +} + +void QtModalProgressCallback::DisplayError(const char* message) +{ + qWarning() << message; +} + +void QtModalProgressCallback::DisplayWarning(const char* message) +{ + qWarning() << message; +} + +void QtModalProgressCallback::DisplayInformation(const char* message) +{ + qWarning() << message; +} + +void QtModalProgressCallback::DisplayDebugMessage(const char* message) +{ + qWarning() << message; +} + +void QtModalProgressCallback::ModalError(const char* message) +{ + QMessageBox::critical(&m_dialog, tr("Error"), QString::fromUtf8(message)); +} + +bool QtModalProgressCallback::ModalConfirmation(const char* message) +{ + return (QMessageBox::question(&m_dialog, tr("Question"), QString::fromUtf8(message), QMessageBox::Yes, + QMessageBox::No) == QMessageBox::Yes); +} + +void QtModalProgressCallback::ModalInformation(const char* message) +{ + QMessageBox::information(&m_dialog, tr("Information"), QString::fromUtf8(message)); +} + +void QtModalProgressCallback::checkForDelayedShow() +{ + if (m_dialog.isVisible()) + return; + + if (m_show_timer.GetTimeSeconds() >= m_show_delay) + { + m_dialog.setRange(0, m_progress_range); + m_dialog.setValue(m_progress_value); + m_dialog.show(); + } +} + +QtAsyncProgressThread::QtAsyncProgressThread(QWidget* parent) + : QThread() +{ + // NOTE: We deliberately don't set the thread parent, because otherwise we can't move it. +} + +QtAsyncProgressThread::~QtAsyncProgressThread() = default; + +bool QtAsyncProgressThread::IsCancelled() const +{ + return isInterruptionRequested(); +} + +void QtAsyncProgressThread::SetCancellable(bool cancellable) +{ + if (m_cancellable == cancellable) + return; + + BaseProgressCallback::SetCancellable(cancellable); +} + +void QtAsyncProgressThread::SetTitle(const char* title) +{ + emit titleUpdated(QString::fromUtf8(title)); +} + +void QtAsyncProgressThread::SetStatusText(const char* text) +{ + BaseProgressCallback::SetStatusText(text); + emit statusUpdated(QString::fromUtf8(text)); +} + +void QtAsyncProgressThread::SetProgressRange(u32 range) +{ + BaseProgressCallback::SetProgressRange(range); + emit progressUpdated(static_cast(m_progress_value), static_cast(m_progress_range)); +} + +void QtAsyncProgressThread::SetProgressValue(u32 value) +{ + BaseProgressCallback::SetProgressValue(value); + emit progressUpdated(static_cast(m_progress_value), static_cast(m_progress_range)); +} + +void QtAsyncProgressThread::DisplayError(const char* message) +{ + qWarning() << message; +} + +void QtAsyncProgressThread::DisplayWarning(const char* message) +{ + qWarning() << message; +} + +void QtAsyncProgressThread::DisplayInformation(const char* message) +{ + qWarning() << message; +} + +void QtAsyncProgressThread::DisplayDebugMessage(const char* message) +{ + qWarning() << message; +} + +void QtAsyncProgressThread::ModalError(const char* message) +{ + QMessageBox::critical(parentWidget(), tr("Error"), QString::fromUtf8(message)); +} + +bool QtAsyncProgressThread::ModalConfirmation(const char* message) +{ + return (QMessageBox::question(parentWidget(), tr("Question"), QString::fromUtf8(message), QMessageBox::Yes, + QMessageBox::No) == QMessageBox::Yes); +} + +void QtAsyncProgressThread::ModalInformation(const char* message) +{ + QMessageBox::information(parentWidget(), tr("Information"), QString::fromUtf8(message)); +} + +void QtAsyncProgressThread::start() +{ + pxAssertRel(!isRunning(), "Async progress thread is not already running"); + + QThread::start(); + moveToThread(this); + m_starting_thread = QThread::currentThread(); + m_start_semaphore.release(); +} + +void QtAsyncProgressThread::join() +{ + if (isRunning()) + QThread::wait(); +} + +void QtAsyncProgressThread::run() +{ + m_start_semaphore.acquire(); + emit threadStarting(); + runAsync(); + emit threadFinished(); + moveToThread(m_starting_thread); +} + +QWidget* QtAsyncProgressThread::parentWidget() const +{ + return qobject_cast(parent()); +} diff --git a/pcsx2-qt/QtProgressCallback.h b/pcsx2-qt/QtProgressCallback.h new file mode 100644 index 0000000000..0916d2e01d --- /dev/null +++ b/pcsx2-qt/QtProgressCallback.h @@ -0,0 +1,102 @@ +/* PCSX2 - PS2 Emulator for PCs + * Copyright (C) 2002-2022 PCSX2 Dev Team + * + * PCSX2 is free software: you can redistribute it and/or modify it under the terms + * of the GNU Lesser General Public License as published by the Free Software Found- + * ation, either version 3 of the License, or (at your option) any later version. + * + * PCSX2 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 PCSX2. + * If not, see . + */ + +#pragma once +#include "common/ProgressCallback.h" +#include "common/Timer.h" +#include +#include +#include +#include + +class QtModalProgressCallback final : public QObject, public BaseProgressCallback +{ + Q_OBJECT + +public: + QtModalProgressCallback(QWidget* parent_widget, float show_delay = 0.0f); + ~QtModalProgressCallback(); + + bool IsCancelled() const override; + + void SetCancellable(bool cancellable) override; + void SetTitle(const char* title) override; + void SetStatusText(const char* text) override; + void SetProgressRange(u32 range) override; + void SetProgressValue(u32 value) override; + + void DisplayError(const char* message) override; + void DisplayWarning(const char* message) override; + void DisplayInformation(const char* message) override; + void DisplayDebugMessage(const char* message) override; + + void ModalError(const char* message) override; + bool ModalConfirmation(const char* message) override; + void ModalInformation(const char* message) override; + +private: + void checkForDelayedShow(); + + QProgressDialog m_dialog; + Common::Timer m_show_timer; + float m_show_delay; +}; + +class QtAsyncProgressThread : public QThread, public BaseProgressCallback +{ + Q_OBJECT + +public: + QtAsyncProgressThread(QWidget* parent); + ~QtAsyncProgressThread(); + + bool IsCancelled() const override; + + void SetCancellable(bool cancellable) override; + void SetTitle(const char* title) override; + void SetStatusText(const char* text) override; + void SetProgressRange(u32 range) override; + void SetProgressValue(u32 value) override; + + void DisplayError(const char* message) override; + void DisplayWarning(const char* message) override; + void DisplayInformation(const char* message) override; + void DisplayDebugMessage(const char* message) override; + + void ModalError(const char* message) override; + bool ModalConfirmation(const char* message) override; + void ModalInformation(const char* message) override; + +Q_SIGNALS: + void titleUpdated(const QString& title); + void statusUpdated(const QString& status); + void progressUpdated(int value, int range); + void threadStarting(); + void threadFinished(); + +public Q_SLOTS: + void start(); + void join(); + +protected: + virtual void runAsync() = 0; + void run() final; + +private: + QWidget* parentWidget() const; + + QSemaphore m_start_semaphore; + QThread* m_starting_thread = nullptr; +}; diff --git a/pcsx2-qt/pcsx2-qt.vcxproj b/pcsx2-qt/pcsx2-qt.vcxproj index 5b54e0b4ef..6d3cf7c10b 100644 --- a/pcsx2-qt/pcsx2-qt.vcxproj +++ b/pcsx2-qt/pcsx2-qt.vcxproj @@ -135,6 +135,7 @@ + @@ -165,6 +166,7 @@ + @@ -193,6 +195,7 @@ + @@ -210,6 +213,7 @@ + @@ -247,9 +251,11 @@ + + NotUsing @@ -328,6 +334,9 @@ Document + + Document + Document diff --git a/pcsx2-qt/pcsx2-qt.vcxproj.filters b/pcsx2-qt/pcsx2-qt.vcxproj.filters index 98aa3f2702..dfe03d74ef 100644 --- a/pcsx2-qt/pcsx2-qt.vcxproj.filters +++ b/pcsx2-qt/pcsx2-qt.vcxproj.filters @@ -221,6 +221,14 @@ moc + + moc + + + + + moc + @@ -229,7 +237,6 @@ - @@ -325,6 +332,9 @@ Settings + + + @@ -409,6 +419,7 @@ Settings + diff --git a/pcsx2-qt/resources/icons/black/svg/image-fill.svg b/pcsx2-qt/resources/icons/black/svg/image-fill.svg new file mode 100644 index 0000000000..25cbfc9c47 --- /dev/null +++ b/pcsx2-qt/resources/icons/black/svg/image-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pcsx2-qt/resources/icons/white/svg/image-fill.svg b/pcsx2-qt/resources/icons/white/svg/image-fill.svg new file mode 100644 index 0000000000..ca68e0ef2d --- /dev/null +++ b/pcsx2-qt/resources/icons/white/svg/image-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pcsx2-qt/resources/resources.qrc b/pcsx2-qt/resources/resources.qrc index f2db7c78c5..c0628e02e9 100644 --- a/pcsx2-qt/resources/resources.qrc +++ b/pcsx2-qt/resources/resources.qrc @@ -33,6 +33,7 @@ icons/black/svg/gamepad-line.svg icons/black/svg/global-line.svg icons/black/svg/hard-drive-2-line.svg + icons/black/svg/image-fill.svg icons/black/svg/keyboard-line.svg icons/black/svg/layout-grid-line.svg icons/black/svg/list-check.svg @@ -87,6 +88,7 @@ icons/white/svg/gamepad-line.svg icons/white/svg/global-line.svg icons/white/svg/hard-drive-2-line.svg + icons/white/svg/image-fill.svg icons/white/svg/keyboard-line.svg icons/white/svg/layout-grid-line.svg icons/white/svg/list-check.svg diff --git a/pcsx2/Frontend/GameList.cpp b/pcsx2/Frontend/GameList.cpp index 92644fd3a1..14488571a3 100644 --- a/pcsx2/Frontend/GameList.cpp +++ b/pcsx2/Frontend/GameList.cpp @@ -20,6 +20,7 @@ #include "common/Assertions.h" #include "common/Console.h" #include "common/FileSystem.h" +#include "common/HTTPDownloader.h" #include "common/Path.h" #include "common/ProgressCallback.h" #include "common/StringUtil.h" @@ -778,7 +779,7 @@ std::string GameList::GetCoverImagePath(const std::string& path, const std::stri return cover_path; } -std::string GameList::GetNewCoverImagePathForEntry(const Entry* entry, const char* new_filename) +std::string GameList::GetNewCoverImagePathForEntry(const Entry* entry, const char* new_filename, bool use_serial) { const char* extension = std::strrchr(new_filename, '.'); if (!extension) @@ -792,7 +793,132 @@ std::string GameList::GetNewCoverImagePathForEntry(const Entry* entry, const cha return existing_filename; } - std::string cover_filename(entry->title + extension); + std::string cover_filename(use_serial ? (entry->serial + extension) : (entry->title + extension)); Path::SanitizeFileName(&cover_filename); return Path::Combine(EmuFolders::Covers, cover_filename); } + +bool GameList::DownloadCovers(const std::vector& url_templates, bool use_serial, ProgressCallback* progress, + std::function save_callback) +{ + if (!progress) + progress = ProgressCallback::NullProgressCallback; + + bool has_title = false; + bool has_file_title = false; + bool has_serial = false; + for (const std::string& url_template : url_templates) + { + if (!has_title && url_template.find("${title}") != std::string::npos) + has_title = true; + if (!has_file_title && url_template.find("${filetitle}") != std::string::npos) + has_file_title = true; + if (!has_serial && url_template.find("${serial}") != std::string::npos) + has_serial = true; + } + if (!has_title && !has_file_title && !has_serial) + { + progress->DisplayError("URL template must contain at least one of ${title}, ${filetitle}, or ${serial}."); + return false; + } + + std::vector> download_urls; + { + std::unique_lock lock(s_mutex); + for (const GameList::Entry& entry : m_entries) + { + const std::string existing_path(GetCoverImagePathForEntry(&entry)); + if (!existing_path.empty()) + continue; + + for (const std::string& url_template : url_templates) + { + std::string url(url_template); + if (has_title) + StringUtil::ReplaceAll(&url, "${title}", Common::HTTPDownloader::URLEncode(entry.title)); + if (has_file_title) + { + std::string display_name(FileSystem::GetDisplayNameFromPath(entry.path)); + StringUtil::ReplaceAll(&url, "${filetitle}", Common::HTTPDownloader::URLEncode(Path::GetFileTitle(display_name))); + } + if (has_serial) + StringUtil::ReplaceAll(&url, "${serial}", Common::HTTPDownloader::URLEncode(entry.serial)); + + download_urls.emplace_back(entry.path, std::move(url)); + } + } + } + if (download_urls.empty()) + { + progress->DisplayError("No URLs to download enumerated."); + return false; + } + + std::unique_ptr downloader(Common::HTTPDownloader::Create()); + if (!downloader) + { + progress->DisplayError("Failed to create HTTP downloader."); + return false; + } + + progress->SetCancellable(true); + progress->SetProgressRange(static_cast(download_urls.size())); + + for (auto& [entry_path, url] : download_urls) + { + if (progress->IsCancelled()) + break; + + // make sure it didn't get done already + { + std::unique_lock lock(s_mutex); + const GameList::Entry* entry = GetEntryForPath(entry_path.c_str()); + if (!entry || !GetCoverImagePathForEntry(entry).empty()) + { + progress->IncrementProgressValue(); + continue; + } + + progress->SetFormattedStatusText("Downloading cover for %s [%s]...", entry->title.c_str(), entry->serial.c_str()); + } + + // we could actually do a few in parallel here... + std::string filename(Common::HTTPDownloader::URLDecode(url)); + downloader->CreateRequest(std::move(url), [use_serial, &save_callback, entry_path = std::move(entry_path), + filename = std::move(filename)](s32 status_code, std::string content_type, Common::HTTPDownloader::Request::Data data) { + if (status_code != Common::HTTPDownloader::HTTP_OK || data.empty()) + return; + + std::unique_lock lock(s_mutex); + const GameList::Entry* entry = GetEntryForPath(entry_path.c_str()); + if (!entry || !GetCoverImagePathForEntry(entry).empty()) + return; + + // prefer the content type from the response for the extension + // otherwise, if it's missing, and the request didn't have an extension.. fall back to jpegs. + std::string template_filename; + std::string content_type_extension(Common::HTTPDownloader::GetExtensionForContentType(content_type)); + + // don't treat the domain name as an extension.. + const std::string::size_type last_slash = filename.find('/'); + const std::string::size_type last_dot = filename.find('.'); + if (!content_type_extension.empty()) + template_filename = fmt::format("cover.{}", content_type_extension); + else if (last_slash != std::string::npos && last_dot != std::string::npos && last_dot > last_slash) + template_filename = Path::GetFileName(filename); + else + template_filename = "cover.jpg"; + + std::string write_path(GetNewCoverImagePathForEntry(entry, template_filename.c_str(), use_serial)); + if (write_path.empty()) + return; + + if (FileSystem::WriteBinaryFile(write_path.c_str(), data.data(), data.size()) && save_callback) + save_callback(entry, std::move(write_path)); + }); + downloader->WaitForAllRequests(); + progress->IncrementProgressValue(); + } + + return true; +} diff --git a/pcsx2/Frontend/GameList.h b/pcsx2/Frontend/GameList.h index f7d59ead65..cab006a24e 100644 --- a/pcsx2/Frontend/GameList.h +++ b/pcsx2/Frontend/GameList.h @@ -17,6 +17,7 @@ #include "GameDatabase.h" #include "common/Pcsx2Defs.h" #include +#include #include #include #include @@ -124,5 +125,10 @@ namespace GameList std::string GetCoverImagePathForEntry(const Entry* entry); std::string GetCoverImagePath(const std::string& path, const std::string& code, const std::string& title); - std::string GetNewCoverImagePathForEntry(const Entry* entry, const char* new_filename); + std::string GetNewCoverImagePathForEntry(const Entry* entry, const char* new_filename, bool use_serial = false); + + /// Downloads covers using the specified URL templates. By default, covers are saved by title, but this can be changed with + /// the use_serial parameter. save_callback optionall takes the entry and the path the new cover is saved to. + bool DownloadCovers(const std::vector& url_templates, bool use_serial = false, ProgressCallback* progress = nullptr, + std::function save_callback = {}); } // namespace GameList