GameList: Add option to download all game icons (#3655)

* GameList: Add option to download all game icons

* Fix translation support in status text calls

* Parallelize downloading game icons
This commit is contained in:
mariobob
2025-12-06 04:27:45 +01:00
committed by GitHub
parent 9b31d8b571
commit 5b91141f09
6 changed files with 226 additions and 0 deletions

View File

@@ -22,6 +22,7 @@
#include "common/assert.h"
#include "common/binary_reader_writer.h"
#include "common/error.h"
#include "common/progress_callback.h"
#include "common/file_system.h"
#include "common/heap_array.h"
#include "common/log.h"
@@ -46,6 +47,7 @@
#include "fmt/format.h"
#include "imgui.h"
#include "imgui_internal.h"
#include "rc_api_info.h"
#include "rc_api_runtime.h"
#include "rc_client.h"
#include "rc_consoles.h"
@@ -2059,6 +2061,195 @@ std::string Achievements::GetGameBadgePath(std::string_view badge_name)
return GetLocalImagePath(badge_name, RC_IMAGE_TYPE_GAME);
}
bool Achievements::DownloadGameIcons(ProgressCallback* progress, Error* error)
{
progress->SetStatusText(TRANSLATE_SV("Achievements", "Collecting games..."));
// Collect all unique game IDs that don't have icons yet
std::vector<u32> game_ids;
{
const auto lock = GameList::GetLock();
for (const GameList::Entry& entry : GameList::GetEntries())
{
if (entry.achievements_game_id != 0)
{
// Check if we already have this badge
const std::string existing_badge = GameList::GetAchievementGameBadgePath(entry.achievements_game_id);
if (existing_badge.empty() &&
std::find(game_ids.begin(), game_ids.end(), entry.achievements_game_id) == game_ids.end())
{
game_ids.push_back(entry.achievements_game_id);
}
}
}
}
if (game_ids.empty())
{
progress->SetStatusText(TRANSLATE_SV("Achievements", "No games need icon downloads."));
return true;
}
INFO_LOG("Downloading icons for {} games from RetroAchievements", game_ids.size());
progress->FormatStatusText(TRANSLATE_FS("Achievements", "Fetching icon info for {} games..."), game_ids.size());
// Create HTTP downloader
std::unique_ptr<HTTPDownloader> http = HTTPDownloader::Create(Host::GetHTTPUserAgent());
if (!http)
{
Error::SetStringView(error, "Failed to create HTTP downloader.");
return false;
}
http->SetTimeout(30.0f);
// Fetch game titles (includes badge names) from RetroAchievements
rc_api_fetch_game_titles_request_t titles_request;
titles_request.game_ids = game_ids.data();
titles_request.num_game_ids = static_cast<u32>(game_ids.size());
rc_api_request_t request;
if (rc_api_init_fetch_game_titles_request(&request, &titles_request) != RC_OK)
{
Error::SetStringView(error, "Failed to create API request.");
return false;
}
std::vector<u8> response_data;
bool request_success = false;
HTTPDownloader::Request::Callback callback = [&response_data, &request_success](
s32 status_code, const Error&, const std::string&,
HTTPDownloader::Request::Data data) {
if (status_code == HTTPDownloader::HTTP_STATUS_OK)
{
response_data = std::move(data);
request_success = true;
}
};
if (request.post_data)
http->CreatePostRequest(request.url, request.post_data, std::move(callback));
else
http->CreateRequest(request.url, std::move(callback));
rc_api_destroy_request(&request);
http->WaitForAllRequests();
if (!request_success || response_data.empty())
{
Error::SetStringView(error, "Failed to fetch game info from RetroAchievements.");
return false;
}
// Parse response
rc_api_fetch_game_titles_response_t titles_response;
rc_api_server_response_t server_response;
server_response.body = reinterpret_cast<const char*>(response_data.data());
server_response.body_length = response_data.size();
server_response.http_status_code = 200;
const int parse_result = rc_api_process_fetch_game_titles_server_response(&titles_response, &server_response);
if (parse_result != RC_OK)
{
const std::string_view response_preview(server_response.body,
std::min<size_t>(server_response.body_length, 500));
ERROR_LOG("Failed to parse game titles response ({}): {}", parse_result, response_preview);
if (titles_response.response.error_message)
Error::SetStringFmt(error, "RetroAchievements error: {}", titles_response.response.error_message);
else
Error::SetStringFmt(error, "Failed to parse API response (code {})", parse_result);
rc_api_destroy_fetch_game_titles_response(&titles_response);
return false;
}
ScopedGuard response_guard([&titles_response]() { rc_api_destroy_fetch_game_titles_response(&titles_response); });
if (titles_response.num_entries == 0)
{
progress->SetStatusText(TRANSLATE_SV("Achievements", "No icon information found."));
return true;
}
// Collect icons to download
struct PendingDownload
{
u32 game_id;
std::string image_name;
std::string local_path;
std::string url;
std::vector<u8> data;
bool success = false;
};
std::vector<PendingDownload> downloads;
downloads.reserve(titles_response.num_entries);
for (u32 i = 0; i < titles_response.num_entries; i++)
{
const rc_api_game_title_entry_t& entry = titles_response.entries[i];
if (!entry.image_name || entry.image_name[0] == '\0')
continue;
std::string local_path = GetLocalImagePath(entry.image_name, RC_IMAGE_TYPE_GAME);
if (FileSystem::FileExists(local_path.c_str()))
{
// Already have this icon, just update the cache
GameList::UpdateAchievementBadgeName(entry.id, entry.image_name);
continue;
}
std::string url = GetImageURL(entry.image_name, RC_IMAGE_TYPE_GAME);
if (url.empty())
continue;
downloads.push_back({entry.id, entry.image_name, std::move(local_path), std::move(url), {}, false});
}
if (downloads.empty())
{
progress->SetStatusText(TRANSLATE_SV("Achievements", "All icons already downloaded."));
return true;
}
// Create all download requests in parallel
progress->SetProgressRange(static_cast<u32>(downloads.size()));
progress->FormatStatusText(TRANSLATE_FS("Achievements", "Downloading {} game icons..."), downloads.size());
std::atomic<u32> completed_count{0};
for (PendingDownload& dl : downloads)
{
http->CreateRequest(dl.url, [&dl, &completed_count, progress](s32 status_code, const Error&, const std::string&,
HTTPDownloader::Request::Data data) {
if (status_code == HTTPDownloader::HTTP_STATUS_OK)
{
dl.data = std::move(data);
dl.success = true;
}
progress->SetProgressValue(completed_count.fetch_add(1, std::memory_order_relaxed) + 1);
});
}
http->WaitForAllRequests();
// Process completed downloads
u32 downloaded = 0;
for (const PendingDownload& dl : downloads)
{
if (dl.success && !dl.data.empty())
{
if (FileSystem::WriteBinaryFile(dl.local_path.c_str(), dl.data))
{
GameList::UpdateAchievementBadgeName(dl.game_id, dl.image_name);
downloaded++;
}
}
}
INFO_LOG("Downloaded {} game icons", downloaded);
return true;
}
u32 Achievements::GetPauseThrottleFrames()
{
if (!IsActive() || !IsHardcoreModeActive())

View File

@@ -16,6 +16,7 @@
#include <vector>
class Error;
class ProgressCallback;
class StateWrapper;
class CDImage;
@@ -184,6 +185,10 @@ SmallString GetLoggedInUserPointsSummary();
/// Returns the path to the local cache for the specified badge name.
std::string GetGameBadgePath(std::string_view badge_name);
/// Downloads game icons from RetroAchievements for all games that have an achievements_game_id.
/// This fetches the game badge images that are normally downloaded when a game is opened.
bool DownloadGameIcons(ProgressCallback* progress, Error* error);
/// Returns 0 if pausing is allowed, otherwise the number of frames until pausing is allowed.
u32 GetPauseThrottleFrames();

View File

@@ -5,9 +5,11 @@
#include "gamelistrefreshthread.h"
#include "mainwindow.h"
#include "qthost.h"
#include "qtprogresscallback.h"
#include "qtutils.h"
#include "settingswindow.h"
#include "core/achievements.h"
#include "core/fullscreenui.h"
#include "core/game_list.h"
#include "core/host.h"
@@ -2161,6 +2163,19 @@ void GameListWidget::setPreferAchievementGameIcons(bool enabled)
m_model->refreshIcons();
}
void GameListWidget::downloadAllGameIcons()
{
QtAsyncTaskWithProgressDialog::create(
this, tr("Loading Game Icons").toStdString(), tr("Downloading game icons...").toStdString(), true, 0, 0, 0.0f,
[](ProgressCallback* progress) -> std::function<void()> {
Error error;
if (!Achievements::DownloadGameIcons(progress, &error))
WARNING_LOG("Failed to download game icons: {}", error.GetDescription());
return []() { g_main_window->refreshGameListModel(); };
});
}
void GameListWidget::setShowCoverTitles(bool enabled)
{
if (m_model->getShowCoverTitles() == enabled)

View File

@@ -283,6 +283,7 @@ public:
void setShowGameIcons(bool enabled);
void setAnimateGameIcons(bool enabled);
void setPreferAchievementGameIcons(bool enabled);
void downloadAllGameIcons();
void setShowCoverTitles(bool enabled);
void refreshGridCovers();
void focusSearchWidget();

View File

@@ -530,6 +530,7 @@ void MainWindow::updateGameListRelatedActions()
m_ui.actionViewZoomOut->setDisabled(disable);
m_ui.actionGridViewRefreshCovers->setDisabled(disable || !game_grid);
m_ui.actionPreferAchievementGameIcons->setDisabled(disable || !game_list);
m_ui.actionDownloadAllGameIcons->setDisabled(disable);
m_ui.actionChangeGameListBackground->setDisabled(disable);
m_ui.actionClearGameListBackground->setDisabled(disable || !has_background);
}
@@ -2491,6 +2492,7 @@ void MainWindow::connectSignals()
connect(m_ui.actionAnimateGameIcons, &QAction::triggered, m_game_list_widget, &GameListWidget::setAnimateGameIcons);
connect(m_ui.actionPreferAchievementGameIcons, &QAction::triggered, m_game_list_widget,
&GameListWidget::setPreferAchievementGameIcons);
connect(m_ui.actionDownloadAllGameIcons, &QAction::triggered, m_game_list_widget, &GameListWidget::downloadAllGameIcons);
connect(m_ui.actionGridViewShowTitles, &QAction::triggered, m_game_list_widget, &GameListWidget::setShowCoverTitles);
connect(m_ui.actionGridViewShowTitles, &QAction::triggered, m_game_list_widget, &GameListWidget::setShowCoverTitles);
connect(m_ui.actionViewZoomIn, &QAction::triggered, this, &MainWindow::onViewZoomInActionTriggered);

View File

@@ -218,6 +218,7 @@
<addaction name="actionShowGameIcons"/>
<addaction name="actionAnimateGameIcons"/>
<addaction name="actionPreferAchievementGameIcons"/>
<addaction name="actionDownloadAllGameIcons"/>
<addaction name="separator"/>
<addaction name="actionGridViewShowTitles"/>
<addaction name="actionGridViewRefreshCovers"/>
@@ -1387,6 +1388,17 @@
<string>Prioritizes the games badges used for RetroAchievements over memory card icons.</string>
</property>
</action>
<action name="actionDownloadAllGameIcons">
<property name="icon">
<iconset theme="download-2-line"/>
</property>
<property name="text">
<string>Download All Game Icons</string>
</property>
<property name="toolTip">
<string>Downloads icons for all games from RetroAchievements.</string>
</property>
</action>
</widget>
<resources>
<include location="resources/duckstation-qt.qrc"/>