Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
191 changes: 191 additions & 0 deletions src/core/achievements.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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())
Expand Down
5 changes: 5 additions & 0 deletions src/core/achievements.h
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
#include <vector>

class Error;
class ProgressCallback;
class StateWrapper;
class CDImage;

Expand Down Expand Up @@ -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();

Expand Down
15 changes: 15 additions & 0 deletions src/duckstation-qt/gamelistwidget.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions src/duckstation-qt/gamelistwidget.h
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,7 @@ class GameListWidget final : public QWidget
void setShowGameIcons(bool enabled);
void setAnimateGameIcons(bool enabled);
void setPreferAchievementGameIcons(bool enabled);
void downloadAllGameIcons();
void setShowCoverTitles(bool enabled);
void refreshGridCovers();
void focusSearchWidget();
Expand Down
2 changes: 2 additions & 0 deletions src/duckstation-qt/mainwindow.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
Expand Down
12 changes: 12 additions & 0 deletions src/duckstation-qt/mainwindow.ui
Original file line number Diff line number Diff line change
Expand Up @@ -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"/>
Expand Down Expand Up @@ -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"/>
Expand Down