2024-05-22 12:46:09 +00:00
|
|
|
// SPDX-FileCopyrightText: 2019-2024 Connor McLaughlin <stenzek@gmail.com>
|
2022-12-04 11:03:45 +00:00
|
|
|
// SPDX-License-Identifier: (GPL-3.0 OR CC-BY-NC-ND-4.0)
|
|
|
|
|
2019-11-29 13:46:04 +00:00
|
|
|
#include "game_list.h"
|
2023-08-13 06:28:28 +00:00
|
|
|
#include "bios.h"
|
2024-05-22 12:46:09 +00:00
|
|
|
#include "fullscreen_ui.h"
|
2023-08-13 06:28:28 +00:00
|
|
|
#include "host.h"
|
2024-07-11 07:15:57 +00:00
|
|
|
#include "memory_card_image.h"
|
2023-08-13 06:28:28 +00:00
|
|
|
#include "psf_loader.h"
|
|
|
|
#include "settings.h"
|
|
|
|
#include "system.h"
|
|
|
|
|
|
|
|
#include "util/cd_image.h"
|
2023-11-06 09:59:02 +00:00
|
|
|
#include "util/http_downloader.h"
|
2024-07-11 07:15:57 +00:00
|
|
|
#include "util/image.h"
|
2024-06-17 13:25:08 +00:00
|
|
|
#include "util/ini_settings_interface.h"
|
2023-08-13 06:28:28 +00:00
|
|
|
|
2020-01-10 03:31:12 +00:00
|
|
|
#include "common/assert.h"
|
|
|
|
#include "common/byte_stream.h"
|
2024-05-23 10:20:16 +00:00
|
|
|
#include "common/error.h"
|
2020-01-10 03:31:12 +00:00
|
|
|
#include "common/file_system.h"
|
2022-10-21 11:02:19 +00:00
|
|
|
#include "common/heterogeneous_containers.h"
|
2020-01-10 03:31:12 +00:00
|
|
|
#include "common/log.h"
|
2022-07-08 11:57:06 +00:00
|
|
|
#include "common/path.h"
|
2020-03-12 05:32:19 +00:00
|
|
|
#include "common/progress_callback.h"
|
2020-01-10 03:31:12 +00:00
|
|
|
#include "common/string_util.h"
|
2023-08-13 06:28:28 +00:00
|
|
|
|
2019-11-29 13:46:04 +00:00
|
|
|
#include <algorithm>
|
2019-12-04 11:54:14 +00:00
|
|
|
#include <array>
|
2019-11-29 13:46:04 +00:00
|
|
|
#include <cctype>
|
2021-04-17 04:23:47 +00:00
|
|
|
#include <ctime>
|
2020-01-08 03:37:43 +00:00
|
|
|
#include <string_view>
|
2023-05-15 13:38:37 +00:00
|
|
|
#include <type_traits>
|
2022-09-09 10:32:21 +00:00
|
|
|
#include <unordered_map>
|
2019-11-29 13:46:04 +00:00
|
|
|
#include <utility>
|
2023-08-13 06:28:28 +00:00
|
|
|
|
2019-11-29 13:46:04 +00:00
|
|
|
Log_SetChannel(GameList);
|
|
|
|
|
2022-10-21 11:02:19 +00:00
|
|
|
#ifdef _WIN32
|
|
|
|
#include "common/windows_headers.h"
|
|
|
|
#endif
|
|
|
|
|
|
|
|
namespace GameList {
|
2023-11-18 06:21:51 +00:00
|
|
|
namespace {
|
|
|
|
|
2022-07-11 13:03:29 +00:00
|
|
|
enum : u32
|
|
|
|
{
|
2023-11-28 04:54:25 +00:00
|
|
|
GAME_LIST_CACHE_SIGNATURE = 0x45434C48,
|
2024-05-22 12:46:09 +00:00
|
|
|
GAME_LIST_CACHE_VERSION = 35,
|
2022-10-21 11:02:19 +00:00
|
|
|
|
|
|
|
PLAYED_TIME_SERIAL_LENGTH = 32,
|
|
|
|
PLAYED_TIME_LAST_TIME_LENGTH = 20, // uint64
|
|
|
|
PLAYED_TIME_TOTAL_TIME_LENGTH = 20, // uint64
|
|
|
|
PLAYED_TIME_LINE_LENGTH =
|
|
|
|
PLAYED_TIME_SERIAL_LENGTH + 1 + PLAYED_TIME_LAST_TIME_LENGTH + 1 + PLAYED_TIME_TOTAL_TIME_LENGTH,
|
2022-07-11 13:03:29 +00:00
|
|
|
};
|
|
|
|
|
2022-10-21 11:02:19 +00:00
|
|
|
struct PlayedTimeEntry
|
|
|
|
{
|
|
|
|
std::time_t last_played_time;
|
|
|
|
std::time_t total_played_time;
|
|
|
|
};
|
|
|
|
|
2024-07-11 07:15:57 +00:00
|
|
|
#pragma pack(push, 1)
|
|
|
|
struct MemcardTimestampCacheEntry
|
|
|
|
{
|
|
|
|
enum : u32
|
|
|
|
{
|
2024-07-14 02:56:37 +00:00
|
|
|
MAX_SERIAL_LENGTH = 31,
|
2024-07-11 07:15:57 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
char serial[MAX_SERIAL_LENGTH];
|
2024-07-14 02:56:37 +00:00
|
|
|
bool icon_was_extracted;
|
2024-07-11 07:15:57 +00:00
|
|
|
s64 memcard_timestamp;
|
|
|
|
};
|
|
|
|
#pragma pack(pop)
|
|
|
|
|
2023-11-18 06:21:51 +00:00
|
|
|
} // namespace
|
|
|
|
|
2023-10-02 11:33:44 +00:00
|
|
|
using CacheMap = PreferUnorderedStringMap<Entry>;
|
|
|
|
using PlayedTimeMap = PreferUnorderedStringMap<PlayedTimeEntry>;
|
2022-07-11 13:03:29 +00:00
|
|
|
|
2023-05-15 13:38:37 +00:00
|
|
|
static_assert(std::is_same_v<decltype(Entry::hash), System::GameHash>);
|
|
|
|
|
2022-07-11 13:03:29 +00:00
|
|
|
static bool GetExeListEntry(const std::string& path, Entry* entry);
|
|
|
|
static bool GetPsfListEntry(const std::string& path, Entry* entry);
|
|
|
|
static bool GetDiscListEntry(const std::string& path, Entry* entry);
|
|
|
|
|
2024-06-17 13:25:08 +00:00
|
|
|
static void ApplyCustomAttributes(const std::string& path, Entry* entry,
|
|
|
|
const INISettingsInterface& custom_attributes_ini);
|
|
|
|
static bool RescanCustomAttributesForPath(const std::string& path, const INISettingsInterface& custom_attributes_ini);
|
|
|
|
static bool GetGameListEntryFromCache(const std::string& path, Entry* entry,
|
|
|
|
const INISettingsInterface& custom_attributes_ini);
|
|
|
|
static Entry* GetMutableEntryForPath(std::string_view path);
|
2022-07-11 13:03:29 +00:00
|
|
|
static void ScanDirectory(const char* path, bool recursive, bool only_cache,
|
2022-10-21 11:02:19 +00:00
|
|
|
const std::vector<std::string>& excluded_paths, const PlayedTimeMap& played_time_map,
|
2024-06-17 13:25:08 +00:00
|
|
|
const INISettingsInterface& custom_attributes_ini, ProgressCallback* progress);
|
|
|
|
static bool AddFileFromCache(const std::string& path, std::time_t timestamp, const PlayedTimeMap& played_time_map,
|
|
|
|
const INISettingsInterface& custom_attributes_ini);
|
2022-10-21 11:02:19 +00:00
|
|
|
static bool ScanFile(std::string path, std::time_t timestamp, std::unique_lock<std::recursive_mutex>& lock,
|
2024-06-17 13:25:08 +00:00
|
|
|
const PlayedTimeMap& played_time_map, const INISettingsInterface& custom_attributes_ini);
|
2022-07-11 13:03:29 +00:00
|
|
|
|
|
|
|
static std::string GetCacheFilename();
|
|
|
|
static void LoadCache();
|
|
|
|
static bool LoadEntriesFromCache(ByteStream* stream);
|
|
|
|
static bool OpenCacheForWriting();
|
|
|
|
static bool WriteEntryToCache(const Entry* entry);
|
|
|
|
static void CloseCacheFileStream();
|
|
|
|
static void DeleteCacheFile();
|
2024-05-18 05:16:54 +00:00
|
|
|
static void CreateDiscSetEntries(const PlayedTimeMap& played_time_map);
|
2022-10-21 11:02:19 +00:00
|
|
|
|
|
|
|
static std::string GetPlayedTimeFile();
|
|
|
|
static bool ParsePlayedTimeLine(char* line, std::string& serial, PlayedTimeEntry& entry);
|
|
|
|
static std::string MakePlayedTimeLine(const std::string& serial, const PlayedTimeEntry& entry);
|
|
|
|
static PlayedTimeMap LoadPlayedTimeMap(const std::string& path);
|
|
|
|
static PlayedTimeEntry UpdatePlayedTimeFile(const std::string& path, const std::string& serial, std::time_t last_time,
|
|
|
|
std::time_t add_time);
|
2024-06-17 13:25:08 +00:00
|
|
|
|
|
|
|
static std::string GetCustomPropertiesFile();
|
2024-07-11 07:15:57 +00:00
|
|
|
|
|
|
|
static FileSystem::ManagedCFilePtr OpenMemoryCardTimestampCache(bool for_write);
|
|
|
|
static bool UpdateMemcardTimestampCache(const MemcardTimestampCacheEntry& entry);
|
|
|
|
|
2022-07-11 13:03:29 +00:00
|
|
|
} // namespace GameList
|
2019-11-29 13:46:04 +00:00
|
|
|
|
2022-10-21 11:02:19 +00:00
|
|
|
static std::vector<GameList::Entry> s_entries;
|
2022-07-11 13:03:29 +00:00
|
|
|
static std::recursive_mutex s_mutex;
|
2022-10-23 04:09:54 +00:00
|
|
|
static GameList::CacheMap s_cache_map;
|
|
|
|
static std::unique_ptr<ByteStream> s_cache_write_stream;
|
2024-07-11 07:15:57 +00:00
|
|
|
static std::vector<GameList::MemcardTimestampCacheEntry> s_memcard_timestamp_cache_entries;
|
2019-11-29 13:46:04 +00:00
|
|
|
|
2023-09-30 11:42:15 +00:00
|
|
|
static bool s_game_list_loaded = false;
|
2022-07-11 13:03:29 +00:00
|
|
|
|
|
|
|
const char* GameList::GetEntryTypeName(EntryType type)
|
2019-12-04 11:54:14 +00:00
|
|
|
{
|
2024-05-18 05:16:54 +00:00
|
|
|
static std::array<const char*, static_cast<int>(EntryType::Count)> names = {
|
|
|
|
{"Disc", "DiscSet", "PSExe", "Playlist", "PSF"}};
|
2019-12-04 11:54:14 +00:00
|
|
|
return names[static_cast<int>(type)];
|
|
|
|
}
|
|
|
|
|
2022-07-11 13:03:29 +00:00
|
|
|
const char* GameList::GetEntryTypeDisplayName(EntryType type)
|
2020-07-06 14:59:28 +00:00
|
|
|
{
|
2022-07-11 13:03:29 +00:00
|
|
|
static std::array<const char*, static_cast<int>(EntryType::Count)> names = {
|
2024-05-18 05:16:54 +00:00
|
|
|
{TRANSLATE_NOOP("GameList", "Disc"), TRANSLATE_NOOP("GameList", "Disc Set"), TRANSLATE_NOOP("GameList", "PS-EXE"),
|
|
|
|
TRANSLATE_NOOP("GameList", "Playlist"), TRANSLATE_NOOP("GameList", "PSF")}};
|
2023-08-20 03:25:43 +00:00
|
|
|
return Host::TranslateToCString("GameList", names[static_cast<int>(type)]);
|
2020-07-06 14:59:28 +00:00
|
|
|
}
|
|
|
|
|
2022-07-11 13:03:29 +00:00
|
|
|
bool GameList::IsGameListLoaded()
|
2020-05-16 10:01:19 +00:00
|
|
|
{
|
2023-09-30 11:42:15 +00:00
|
|
|
return s_game_list_loaded;
|
2020-05-16 10:01:19 +00:00
|
|
|
}
|
|
|
|
|
2024-05-05 10:21:54 +00:00
|
|
|
bool GameList::IsScannableFilename(std::string_view path)
|
2021-02-18 08:07:09 +00:00
|
|
|
{
|
|
|
|
// we don't scan bin files because they'll duplicate
|
2022-07-11 13:03:29 +00:00
|
|
|
if (StringUtil::EndsWithNoCase(path, ".bin"))
|
2021-02-18 08:07:09 +00:00
|
|
|
return false;
|
|
|
|
|
2022-07-11 13:03:29 +00:00
|
|
|
return System::IsLoadableFilename(path);
|
2021-02-18 08:07:09 +00:00
|
|
|
}
|
|
|
|
|
2022-07-11 13:03:29 +00:00
|
|
|
bool GameList::GetExeListEntry(const std::string& path, GameList::Entry* entry)
|
2019-12-04 11:12:50 +00:00
|
|
|
{
|
2021-04-17 09:13:58 +00:00
|
|
|
std::FILE* fp = FileSystem::OpenCFile(path.c_str(), "rb");
|
2019-12-04 11:12:50 +00:00
|
|
|
if (!fp)
|
|
|
|
return false;
|
|
|
|
|
|
|
|
std::fseek(fp, 0, SEEK_END);
|
|
|
|
const u32 file_size = static_cast<u32>(std::ftell(fp));
|
|
|
|
std::fseek(fp, 0, SEEK_SET);
|
|
|
|
|
|
|
|
BIOS::PSEXEHeader header;
|
|
|
|
if (std::fread(&header, sizeof(header), 1, fp) != 1)
|
|
|
|
{
|
|
|
|
std::fclose(fp);
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
std::fclose(fp);
|
|
|
|
|
|
|
|
if (!BIOS::IsValidPSExeHeader(header, file_size))
|
|
|
|
{
|
2024-05-23 10:55:28 +00:00
|
|
|
WARNING_LOG("{} is not a valid PS-EXE", path);
|
2019-12-04 11:12:50 +00:00
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
2024-05-30 12:21:51 +00:00
|
|
|
const System::GameHash hash = System::GetGameHashFromFile(path.c_str());
|
|
|
|
|
|
|
|
entry->serial = hash ? System::GetGameHashId(hash) : std::string();
|
|
|
|
entry->title = Path::GetFileTitle(FileSystem::GetDisplayNameFromPath(path));
|
2021-01-24 04:06:35 +00:00
|
|
|
entry->region = BIOS::GetPSExeDiscRegion(header);
|
2023-12-20 13:40:24 +00:00
|
|
|
entry->file_size = ZeroExtend64(file_size);
|
|
|
|
entry->uncompressed_size = entry->file_size;
|
2022-07-11 13:03:29 +00:00
|
|
|
entry->type = EntryType::PSExe;
|
|
|
|
entry->compatibility = GameDatabase::CompatibilityRating::Unknown;
|
2019-12-04 11:12:50 +00:00
|
|
|
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
2022-07-11 13:03:29 +00:00
|
|
|
bool GameList::GetPsfListEntry(const std::string& path, Entry* entry)
|
2021-01-24 04:06:52 +00:00
|
|
|
{
|
2021-04-17 09:13:58 +00:00
|
|
|
// we don't need to walk the library chain here - the top file is enough
|
2021-01-24 04:06:52 +00:00
|
|
|
PSFLoader::File file;
|
2021-04-17 09:13:58 +00:00
|
|
|
if (!file.Load(path.c_str()))
|
2021-01-24 04:06:52 +00:00
|
|
|
return false;
|
|
|
|
|
2022-07-11 13:03:29 +00:00
|
|
|
entry->serial.clear();
|
2021-01-24 04:06:52 +00:00
|
|
|
entry->region = file.GetRegion();
|
2023-12-20 13:40:24 +00:00
|
|
|
entry->file_size = static_cast<u32>(file.GetProgramData().size());
|
|
|
|
entry->uncompressed_size = entry->file_size;
|
2022-07-11 13:03:29 +00:00
|
|
|
entry->type = EntryType::PSF;
|
|
|
|
entry->compatibility = GameDatabase::CompatibilityRating::Unknown;
|
2021-01-24 04:06:52 +00:00
|
|
|
|
|
|
|
// Game - Title
|
|
|
|
std::optional<std::string> game(file.GetTagString("game"));
|
|
|
|
if (game.has_value())
|
|
|
|
{
|
|
|
|
entry->title = std::move(game.value());
|
|
|
|
entry->title += " - ";
|
|
|
|
}
|
|
|
|
else
|
|
|
|
{
|
|
|
|
entry->title.clear();
|
|
|
|
}
|
|
|
|
|
|
|
|
std::optional<std::string> title(file.GetTagString("title"));
|
|
|
|
if (title.has_value())
|
2021-08-20 14:01:35 +00:00
|
|
|
{
|
2021-01-24 04:06:52 +00:00
|
|
|
entry->title += title.value();
|
2021-08-20 14:01:35 +00:00
|
|
|
}
|
2021-01-24 04:06:52 +00:00
|
|
|
else
|
2021-08-20 14:01:35 +00:00
|
|
|
{
|
|
|
|
const std::string display_name(FileSystem::GetDisplayNameFromPath(path));
|
2022-08-10 03:03:40 +00:00
|
|
|
entry->title += Path::GetFileTitle(display_name);
|
2021-08-20 14:01:35 +00:00
|
|
|
}
|
2021-01-24 04:06:52 +00:00
|
|
|
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
2022-07-11 13:03:29 +00:00
|
|
|
bool GameList::GetDiscListEntry(const std::string& path, Entry* entry)
|
2019-11-29 13:46:04 +00:00
|
|
|
{
|
2022-08-27 06:52:24 +00:00
|
|
|
std::unique_ptr<CDImage> cdi = CDImage::Open(path.c_str(), false, nullptr);
|
2019-11-29 13:46:04 +00:00
|
|
|
if (!cdi)
|
|
|
|
return false;
|
|
|
|
|
|
|
|
entry->path = path;
|
2023-12-20 13:40:24 +00:00
|
|
|
entry->file_size = cdi->GetSizeOnDisk();
|
|
|
|
entry->uncompressed_size = static_cast<u64>(CDImage::RAW_SECTOR_SIZE) * static_cast<u64>(cdi->GetLBACount());
|
2022-07-11 13:03:29 +00:00
|
|
|
entry->type = EntryType::Disc;
|
|
|
|
entry->compatibility = GameDatabase::CompatibilityRating::Unknown;
|
2019-11-30 13:55:05 +00:00
|
|
|
|
2023-05-15 13:38:37 +00:00
|
|
|
std::string id;
|
|
|
|
System::GetGameDetailsFromImage(cdi.get(), &id, &entry->hash);
|
|
|
|
|
2021-04-17 04:23:47 +00:00
|
|
|
// try the database first
|
2023-11-29 13:05:27 +00:00
|
|
|
const GameDatabase::Entry* dentry = GameDatabase::GetEntryForGameDetails(id, entry->hash);
|
2022-07-11 13:03:29 +00:00
|
|
|
if (dentry)
|
|
|
|
{
|
|
|
|
// pull from database
|
|
|
|
entry->serial = dentry->serial;
|
|
|
|
entry->title = dentry->title;
|
|
|
|
entry->genre = dentry->genre;
|
|
|
|
entry->publisher = dentry->publisher;
|
|
|
|
entry->developer = dentry->developer;
|
|
|
|
entry->release_date = dentry->release_date;
|
|
|
|
entry->min_players = dentry->min_players;
|
|
|
|
entry->max_players = dentry->max_players;
|
|
|
|
entry->min_blocks = dentry->min_blocks;
|
|
|
|
entry->max_blocks = dentry->max_blocks;
|
|
|
|
entry->supported_controllers = dentry->supported_controllers;
|
|
|
|
entry->compatibility = dentry->compatibility;
|
2023-11-28 04:54:25 +00:00
|
|
|
|
|
|
|
if (!cdi->HasSubImages())
|
|
|
|
{
|
|
|
|
for (size_t i = 0; i < dentry->disc_set_serials.size(); i++)
|
|
|
|
{
|
|
|
|
if (dentry->disc_set_serials[i] == entry->serial)
|
|
|
|
{
|
|
|
|
entry->disc_set_name = dentry->disc_set_name;
|
|
|
|
entry->disc_set_index = static_cast<s8>(i);
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2022-07-11 13:03:29 +00:00
|
|
|
}
|
|
|
|
else
|
2019-11-30 13:55:05 +00:00
|
|
|
{
|
2022-08-10 03:03:40 +00:00
|
|
|
const std::string display_name(FileSystem::GetDisplayNameFromPath(path));
|
2022-07-08 11:57:06 +00:00
|
|
|
|
2020-01-08 03:37:43 +00:00
|
|
|
// no game code, so use the filename title
|
2023-05-15 13:38:37 +00:00
|
|
|
entry->serial = std::move(id);
|
2022-07-08 11:57:06 +00:00
|
|
|
entry->title = Path::GetFileTitle(display_name);
|
2022-07-11 13:03:29 +00:00
|
|
|
entry->compatibility = GameDatabase::CompatibilityRating::Unknown;
|
2021-04-17 04:23:47 +00:00
|
|
|
entry->release_date = 0;
|
|
|
|
entry->min_players = 0;
|
|
|
|
entry->max_players = 0;
|
|
|
|
entry->min_blocks = 0;
|
|
|
|
entry->max_blocks = 0;
|
2023-08-23 07:32:12 +00:00
|
|
|
entry->supported_controllers = static_cast<u16>(~0u);
|
2019-11-30 13:55:05 +00:00
|
|
|
}
|
2020-03-12 03:51:29 +00:00
|
|
|
|
2021-04-17 04:23:47 +00:00
|
|
|
// region detection
|
2023-05-15 13:38:37 +00:00
|
|
|
entry->region = System::GetRegionForImage(cdi.get());
|
2019-11-30 13:55:05 +00:00
|
|
|
|
2021-03-26 16:19:23 +00:00
|
|
|
if (cdi->HasSubImages())
|
|
|
|
{
|
2022-07-11 13:03:29 +00:00
|
|
|
entry->type = EntryType::Playlist;
|
2021-03-26 16:19:23 +00:00
|
|
|
|
|
|
|
std::string image_title(cdi->GetMetadata("title"));
|
|
|
|
if (!image_title.empty())
|
|
|
|
entry->title = std::move(image_title);
|
|
|
|
|
|
|
|
// get the size of all the subimages
|
|
|
|
const u32 subimage_count = cdi->GetSubImageCount();
|
|
|
|
for (u32 i = 1; i < subimage_count; i++)
|
|
|
|
{
|
|
|
|
if (!cdi->SwitchSubImage(i, nullptr))
|
|
|
|
{
|
2024-05-23 10:55:28 +00:00
|
|
|
ERROR_LOG("Failed to switch to subimage {} in '{}'", i, entry->path);
|
2021-03-26 16:19:23 +00:00
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
2023-12-20 13:40:24 +00:00
|
|
|
entry->uncompressed_size += static_cast<u64>(CDImage::RAW_SECTOR_SIZE) * static_cast<u64>(cdi->GetLBACount());
|
2021-03-26 16:19:23 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-01-08 03:37:43 +00:00
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
2022-07-11 13:03:29 +00:00
|
|
|
bool GameList::PopulateEntryFromPath(const std::string& path, Entry* entry)
|
|
|
|
{
|
|
|
|
if (System::IsExeFileName(path))
|
|
|
|
return GetExeListEntry(path, entry);
|
|
|
|
if (System::IsPsfFileName(path.c_str()))
|
|
|
|
return GetPsfListEntry(path, entry);
|
|
|
|
return GetDiscListEntry(path, entry);
|
|
|
|
}
|
|
|
|
|
2024-06-17 13:25:08 +00:00
|
|
|
bool GameList::GetGameListEntryFromCache(const std::string& path, Entry* entry,
|
|
|
|
const INISettingsInterface& custom_attributes_ini)
|
2020-01-08 03:37:43 +00:00
|
|
|
{
|
2023-09-05 11:03:49 +00:00
|
|
|
auto iter = s_cache_map.find(path);
|
2022-10-23 04:09:54 +00:00
|
|
|
if (iter == s_cache_map.end())
|
2020-01-08 03:37:43 +00:00
|
|
|
return false;
|
|
|
|
|
|
|
|
*entry = std::move(iter->second);
|
2022-10-23 04:09:54 +00:00
|
|
|
s_cache_map.erase(iter);
|
2024-06-17 13:25:08 +00:00
|
|
|
ApplyCustomAttributes(path, entry, custom_attributes_ini);
|
2019-11-29 13:46:04 +00:00
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
2020-01-08 03:37:43 +00:00
|
|
|
bool GameList::LoadEntriesFromCache(ByteStream* stream)
|
|
|
|
{
|
2020-01-10 03:31:12 +00:00
|
|
|
u32 file_signature, file_version;
|
2022-07-11 13:03:29 +00:00
|
|
|
if (!stream->ReadU32(&file_signature) || !stream->ReadU32(&file_version) ||
|
2020-01-10 03:31:12 +00:00
|
|
|
file_signature != GAME_LIST_CACHE_SIGNATURE || file_version != GAME_LIST_CACHE_VERSION)
|
2020-01-08 03:37:43 +00:00
|
|
|
{
|
2024-05-23 10:55:28 +00:00
|
|
|
WARNING_LOG("Game list cache is corrupted");
|
2020-01-08 03:37:43 +00:00
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
while (stream->GetPosition() != stream->GetSize())
|
|
|
|
{
|
2020-01-10 03:31:12 +00:00
|
|
|
std::string path;
|
2022-07-11 13:03:29 +00:00
|
|
|
Entry ge;
|
2021-04-17 04:23:47 +00:00
|
|
|
|
2020-01-10 03:31:12 +00:00
|
|
|
u8 type;
|
2021-04-17 04:23:47 +00:00
|
|
|
u8 region;
|
2020-05-16 10:01:19 +00:00
|
|
|
u8 compatibility_rating;
|
2020-01-10 03:31:12 +00:00
|
|
|
|
2022-07-11 13:03:29 +00:00
|
|
|
if (!stream->ReadU8(&type) || !stream->ReadU8(®ion) || !stream->ReadSizePrefixedString(&path) ||
|
|
|
|
!stream->ReadSizePrefixedString(&ge.serial) || !stream->ReadSizePrefixedString(&ge.title) ||
|
2023-11-28 04:54:25 +00:00
|
|
|
!stream->ReadSizePrefixedString(&ge.disc_set_name) || !stream->ReadSizePrefixedString(&ge.genre) ||
|
|
|
|
!stream->ReadSizePrefixedString(&ge.publisher) || !stream->ReadSizePrefixedString(&ge.developer) ||
|
2023-12-20 13:40:24 +00:00
|
|
|
!stream->ReadU64(&ge.hash) || !stream->ReadS64(&ge.file_size) || !stream->ReadU64(&ge.uncompressed_size) ||
|
2023-11-28 04:54:25 +00:00
|
|
|
!stream->ReadU64(reinterpret_cast<u64*>(&ge.last_modified_time)) || !stream->ReadU64(&ge.release_date) ||
|
|
|
|
!stream->ReadU16(&ge.supported_controllers) || !stream->ReadU8(&ge.min_players) ||
|
|
|
|
!stream->ReadU8(&ge.max_players) || !stream->ReadU8(&ge.min_blocks) || !stream->ReadU8(&ge.max_blocks) ||
|
|
|
|
!stream->ReadS8(&ge.disc_set_index) || !stream->ReadU8(&compatibility_rating) ||
|
2023-05-15 13:38:37 +00:00
|
|
|
region >= static_cast<u8>(DiscRegion::Count) || type >= static_cast<u8>(EntryType::Count) ||
|
2022-07-11 13:03:29 +00:00
|
|
|
compatibility_rating >= static_cast<u8>(GameDatabase::CompatibilityRating::Count))
|
2020-01-08 03:37:43 +00:00
|
|
|
{
|
2024-05-23 10:55:28 +00:00
|
|
|
WARNING_LOG("Game list cache entry is corrupted");
|
2020-01-08 03:37:43 +00:00
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
2020-01-22 07:56:58 +00:00
|
|
|
ge.path = path;
|
2020-03-12 03:51:29 +00:00
|
|
|
ge.region = static_cast<DiscRegion>(region);
|
2022-07-11 13:03:29 +00:00
|
|
|
ge.type = static_cast<EntryType>(type);
|
|
|
|
ge.compatibility = static_cast<GameDatabase::CompatibilityRating>(compatibility_rating);
|
2020-08-20 11:30:11 +00:00
|
|
|
|
2023-09-05 11:03:49 +00:00
|
|
|
auto iter = s_cache_map.find(ge.path);
|
2022-10-23 04:09:54 +00:00
|
|
|
if (iter != s_cache_map.end())
|
2020-01-08 03:37:43 +00:00
|
|
|
iter->second = std::move(ge);
|
|
|
|
else
|
2022-10-23 04:09:54 +00:00
|
|
|
s_cache_map.emplace(std::move(path), std::move(ge));
|
2020-01-08 03:37:43 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
2022-07-11 13:03:29 +00:00
|
|
|
bool GameList::WriteEntryToCache(const Entry* entry)
|
2020-01-08 03:37:43 +00:00
|
|
|
{
|
2022-07-11 13:03:29 +00:00
|
|
|
bool result = true;
|
2022-10-23 04:09:54 +00:00
|
|
|
result &= s_cache_write_stream->WriteU8(static_cast<u8>(entry->type));
|
|
|
|
result &= s_cache_write_stream->WriteU8(static_cast<u8>(entry->region));
|
|
|
|
result &= s_cache_write_stream->WriteSizePrefixedString(entry->path);
|
|
|
|
result &= s_cache_write_stream->WriteSizePrefixedString(entry->serial);
|
|
|
|
result &= s_cache_write_stream->WriteSizePrefixedString(entry->title);
|
2023-11-28 04:54:25 +00:00
|
|
|
result &= s_cache_write_stream->WriteSizePrefixedString(entry->disc_set_name);
|
2022-10-23 04:09:54 +00:00
|
|
|
result &= s_cache_write_stream->WriteSizePrefixedString(entry->genre);
|
|
|
|
result &= s_cache_write_stream->WriteSizePrefixedString(entry->publisher);
|
|
|
|
result &= s_cache_write_stream->WriteSizePrefixedString(entry->developer);
|
2023-05-15 13:38:37 +00:00
|
|
|
result &= s_cache_write_stream->WriteU64(entry->hash);
|
2023-12-20 13:40:24 +00:00
|
|
|
result &= s_cache_write_stream->WriteS64(entry->file_size);
|
|
|
|
result &= s_cache_write_stream->WriteU64(entry->uncompressed_size);
|
2022-10-23 04:09:54 +00:00
|
|
|
result &= s_cache_write_stream->WriteU64(entry->last_modified_time);
|
|
|
|
result &= s_cache_write_stream->WriteU64(entry->release_date);
|
2023-08-23 07:32:12 +00:00
|
|
|
result &= s_cache_write_stream->WriteU16(entry->supported_controllers);
|
2022-10-23 04:09:54 +00:00
|
|
|
result &= s_cache_write_stream->WriteU8(entry->min_players);
|
|
|
|
result &= s_cache_write_stream->WriteU8(entry->max_players);
|
|
|
|
result &= s_cache_write_stream->WriteU8(entry->min_blocks);
|
|
|
|
result &= s_cache_write_stream->WriteU8(entry->max_blocks);
|
2023-11-28 04:54:25 +00:00
|
|
|
result &= s_cache_write_stream->WriteS8(entry->disc_set_index);
|
2022-10-23 04:09:54 +00:00
|
|
|
result &= s_cache_write_stream->WriteU8(static_cast<u8>(entry->compatibility));
|
2022-07-11 13:03:29 +00:00
|
|
|
return result;
|
|
|
|
}
|
2020-01-08 03:37:43 +00:00
|
|
|
|
2022-07-11 13:03:29 +00:00
|
|
|
static std::string GameList::GetCacheFilename()
|
|
|
|
{
|
|
|
|
return Path::Combine(EmuFolders::Cache, "gamelist.cache");
|
|
|
|
}
|
|
|
|
|
|
|
|
void GameList::LoadCache()
|
|
|
|
{
|
|
|
|
std::string filename(GetCacheFilename());
|
|
|
|
std::unique_ptr<ByteStream> stream =
|
|
|
|
ByteStream::OpenFile(filename.c_str(), BYTESTREAM_OPEN_READ | BYTESTREAM_OPEN_STREAMED);
|
|
|
|
if (!stream)
|
|
|
|
return;
|
|
|
|
|
|
|
|
if (!LoadEntriesFromCache(stream.get()))
|
2020-09-09 14:23:24 +00:00
|
|
|
{
|
2024-05-23 10:55:28 +00:00
|
|
|
WARNING_LOG("Deleting corrupted cache file '{}'", Path::GetFileName(filename));
|
2022-07-11 13:03:29 +00:00
|
|
|
stream.reset();
|
2022-10-23 04:09:54 +00:00
|
|
|
s_cache_map.clear();
|
2022-07-11 13:03:29 +00:00
|
|
|
DeleteCacheFile();
|
|
|
|
return;
|
2020-09-09 14:23:24 +00:00
|
|
|
}
|
2022-07-11 13:03:29 +00:00
|
|
|
}
|
2020-01-08 03:37:43 +00:00
|
|
|
|
2022-07-11 13:03:29 +00:00
|
|
|
bool GameList::OpenCacheForWriting()
|
|
|
|
{
|
|
|
|
const std::string cache_filename(GetCacheFilename());
|
2022-10-23 04:09:54 +00:00
|
|
|
Assert(!s_cache_write_stream);
|
2022-07-11 13:03:29 +00:00
|
|
|
|
2022-10-23 04:09:54 +00:00
|
|
|
s_cache_write_stream = ByteStream::OpenFile(cache_filename.c_str(),
|
2022-07-11 13:03:29 +00:00
|
|
|
BYTESTREAM_OPEN_READ | BYTESTREAM_OPEN_WRITE | BYTESTREAM_OPEN_SEEKABLE);
|
2022-10-23 04:09:54 +00:00
|
|
|
if (s_cache_write_stream)
|
2020-01-08 03:37:43 +00:00
|
|
|
{
|
2022-07-11 13:03:29 +00:00
|
|
|
// check the header
|
|
|
|
u32 signature, version;
|
2022-10-23 04:09:54 +00:00
|
|
|
if (s_cache_write_stream->ReadU32(&signature) && signature == GAME_LIST_CACHE_SIGNATURE &&
|
|
|
|
s_cache_write_stream->ReadU32(&version) && version == GAME_LIST_CACHE_VERSION &&
|
|
|
|
s_cache_write_stream->SeekToEnd())
|
2020-01-08 03:37:43 +00:00
|
|
|
{
|
2022-07-11 13:03:29 +00:00
|
|
|
return true;
|
2020-01-08 03:37:43 +00:00
|
|
|
}
|
|
|
|
|
2022-10-23 04:09:54 +00:00
|
|
|
s_cache_write_stream.reset();
|
2022-07-11 13:03:29 +00:00
|
|
|
}
|
2020-01-08 03:37:43 +00:00
|
|
|
|
2024-05-23 10:55:28 +00:00
|
|
|
INFO_LOG("Creating new game list cache file: '{}'", Path::GetFileName(cache_filename));
|
2020-01-08 03:37:43 +00:00
|
|
|
|
2022-10-23 04:09:54 +00:00
|
|
|
s_cache_write_stream = ByteStream::OpenFile(
|
2022-07-11 13:03:29 +00:00
|
|
|
cache_filename.c_str(), BYTESTREAM_OPEN_CREATE | BYTESTREAM_OPEN_TRUNCATE | BYTESTREAM_OPEN_WRITE);
|
2022-10-23 04:09:54 +00:00
|
|
|
if (!s_cache_write_stream)
|
2022-07-11 13:03:29 +00:00
|
|
|
return false;
|
|
|
|
|
|
|
|
// new cache file, write header
|
2022-10-23 04:09:54 +00:00
|
|
|
if (!s_cache_write_stream->WriteU32(GAME_LIST_CACHE_SIGNATURE) ||
|
|
|
|
!s_cache_write_stream->WriteU32(GAME_LIST_CACHE_VERSION))
|
2022-07-11 13:03:29 +00:00
|
|
|
{
|
2024-05-23 10:55:28 +00:00
|
|
|
ERROR_LOG("Failed to write game list cache header");
|
2022-10-23 04:09:54 +00:00
|
|
|
s_cache_write_stream.reset();
|
2022-07-11 13:03:29 +00:00
|
|
|
FileSystem::DeleteFile(cache_filename.c_str());
|
|
|
|
return false;
|
|
|
|
}
|
2020-04-25 05:23:36 +00:00
|
|
|
|
2022-07-11 13:03:29 +00:00
|
|
|
return true;
|
2020-04-25 05:23:36 +00:00
|
|
|
}
|
|
|
|
|
2020-01-08 03:37:43 +00:00
|
|
|
void GameList::CloseCacheFileStream()
|
|
|
|
{
|
2022-10-23 04:09:54 +00:00
|
|
|
if (!s_cache_write_stream)
|
2020-01-08 03:37:43 +00:00
|
|
|
return;
|
|
|
|
|
2022-10-23 04:09:54 +00:00
|
|
|
s_cache_write_stream->Commit();
|
|
|
|
s_cache_write_stream.reset();
|
2020-01-08 03:37:43 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
void GameList::DeleteCacheFile()
|
|
|
|
{
|
2022-10-23 04:09:54 +00:00
|
|
|
Assert(!s_cache_write_stream);
|
2022-07-11 13:03:29 +00:00
|
|
|
|
|
|
|
const std::string filename(GetCacheFilename());
|
|
|
|
if (!FileSystem::FileExists(filename.c_str()))
|
2020-01-08 03:37:43 +00:00
|
|
|
return;
|
|
|
|
|
2024-05-23 10:20:16 +00:00
|
|
|
Error error;
|
|
|
|
if (FileSystem::DeleteFile(filename.c_str(), &error))
|
2024-05-23 10:55:28 +00:00
|
|
|
INFO_LOG("Deleted game list cache '{}'", Path::GetFileName(filename));
|
2020-01-08 03:37:43 +00:00
|
|
|
else
|
2024-05-23 10:55:28 +00:00
|
|
|
WARNING_LOG("Failed to delete game list cache '{}': {}", Path::GetFileName(filename), error.GetDescription());
|
2020-01-08 03:37:43 +00:00
|
|
|
}
|
|
|
|
|
2022-07-11 13:03:29 +00:00
|
|
|
static bool IsPathExcluded(const std::vector<std::string>& excluded_paths, const std::string& path)
|
2019-11-29 13:46:04 +00:00
|
|
|
{
|
2024-05-05 12:11:03 +00:00
|
|
|
return std::find_if(excluded_paths.begin(), excluded_paths.end(),
|
|
|
|
[&path](const std::string& entry) { return path.starts_with(entry); }) != excluded_paths.end();
|
2022-07-11 13:03:29 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
void GameList::ScanDirectory(const char* path, bool recursive, bool only_cache,
|
2022-10-21 11:02:19 +00:00
|
|
|
const std::vector<std::string>& excluded_paths, const PlayedTimeMap& played_time_map,
|
2024-06-17 13:25:08 +00:00
|
|
|
const INISettingsInterface& custom_attributes_ini, ProgressCallback* progress)
|
2022-07-11 13:03:29 +00:00
|
|
|
{
|
2024-05-23 10:55:28 +00:00
|
|
|
INFO_LOG("Scanning {}{}", path, recursive ? " (recursively)" : "");
|
2019-11-29 13:46:04 +00:00
|
|
|
|
2024-05-23 10:20:16 +00:00
|
|
|
progress->SetStatusText(SmallString::from_format(TRANSLATE_FS("GameList", "Scanning directory '{}'..."), path));
|
2020-03-12 05:32:19 +00:00
|
|
|
|
2019-11-29 13:46:04 +00:00
|
|
|
FileSystem::FindResultsArray files;
|
2021-07-21 10:25:52 +00:00
|
|
|
FileSystem::FindFiles(path, "*",
|
|
|
|
recursive ? (FILESYSTEM_FIND_FILES | FILESYSTEM_FIND_HIDDEN_FILES | FILESYSTEM_FIND_RECURSIVE) :
|
|
|
|
(FILESYSTEM_FIND_FILES | FILESYSTEM_FIND_HIDDEN_FILES),
|
|
|
|
&files);
|
2022-07-22 13:33:59 +00:00
|
|
|
if (files.empty())
|
|
|
|
return;
|
2019-11-29 13:46:04 +00:00
|
|
|
|
2022-07-22 13:33:59 +00:00
|
|
|
progress->PushState();
|
2020-03-12 05:32:19 +00:00
|
|
|
progress->SetProgressRange(static_cast<u32>(files.size()));
|
|
|
|
progress->SetProgressValue(0);
|
|
|
|
|
2022-07-22 13:33:59 +00:00
|
|
|
u32 files_scanned = 0;
|
2021-04-17 09:13:58 +00:00
|
|
|
for (FILESYSTEM_FIND_DATA& ffd : files)
|
2019-11-29 13:46:04 +00:00
|
|
|
{
|
2022-07-11 13:03:29 +00:00
|
|
|
files_scanned++;
|
2019-11-29 13:46:04 +00:00
|
|
|
|
2022-07-11 13:03:29 +00:00
|
|
|
if (progress->IsCancelled() || !GameList::IsScannableFilename(ffd.FileName) ||
|
|
|
|
IsPathExcluded(excluded_paths, ffd.FileName))
|
|
|
|
{
|
2020-01-08 03:37:43 +00:00
|
|
|
continue;
|
2022-07-11 13:03:29 +00:00
|
|
|
}
|
2020-01-08 03:37:43 +00:00
|
|
|
|
2022-10-21 11:02:19 +00:00
|
|
|
std::unique_lock lock(s_mutex);
|
2024-06-17 13:25:08 +00:00
|
|
|
if (GetEntryForPath(ffd.FileName) ||
|
|
|
|
AddFileFromCache(ffd.FileName, ffd.ModificationTime, played_time_map, custom_attributes_ini) || only_cache)
|
2022-07-11 13:03:29 +00:00
|
|
|
{
|
2022-10-21 11:02:19 +00:00
|
|
|
continue;
|
2022-07-11 13:03:29 +00:00
|
|
|
}
|
2020-03-12 05:32:19 +00:00
|
|
|
|
2024-05-23 10:20:16 +00:00
|
|
|
progress->SetStatusText(SmallString::from_format(TRANSLATE_FS("GameList", "Scanning '{}'..."),
|
|
|
|
FileSystem::GetDisplayNameFromPath(ffd.FileName)));
|
2024-06-17 13:25:08 +00:00
|
|
|
ScanFile(std::move(ffd.FileName), ffd.ModificationTime, lock, played_time_map, custom_attributes_ini);
|
2022-07-11 13:03:29 +00:00
|
|
|
progress->SetProgressValue(files_scanned);
|
2019-11-29 13:46:04 +00:00
|
|
|
}
|
2020-03-12 05:32:19 +00:00
|
|
|
|
2022-07-11 13:03:29 +00:00
|
|
|
progress->SetProgressValue(files_scanned);
|
2020-03-12 05:32:19 +00:00
|
|
|
progress->PopState();
|
2019-11-29 13:46:04 +00:00
|
|
|
}
|
2019-11-30 13:55:05 +00:00
|
|
|
|
2024-06-17 13:25:08 +00:00
|
|
|
bool GameList::AddFileFromCache(const std::string& path, std::time_t timestamp, const PlayedTimeMap& played_time_map,
|
|
|
|
const INISettingsInterface& custom_attributes_ini)
|
2021-04-17 09:13:58 +00:00
|
|
|
{
|
2022-07-11 13:03:29 +00:00
|
|
|
Entry entry;
|
2024-06-17 13:25:08 +00:00
|
|
|
if (!GetGameListEntryFromCache(path, &entry, custom_attributes_ini) || entry.last_modified_time != timestamp)
|
2021-04-17 09:13:58 +00:00
|
|
|
return false;
|
|
|
|
|
2023-09-05 11:03:49 +00:00
|
|
|
auto iter = played_time_map.find(entry.serial);
|
2022-10-21 11:02:19 +00:00
|
|
|
if (iter != played_time_map.end())
|
|
|
|
{
|
|
|
|
entry.last_played_time = iter->second.last_played_time;
|
|
|
|
entry.total_played_time = iter->second.total_played_time;
|
|
|
|
}
|
|
|
|
|
|
|
|
s_entries.push_back(std::move(entry));
|
2021-04-17 09:13:58 +00:00
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
2022-10-21 11:02:19 +00:00
|
|
|
bool GameList::ScanFile(std::string path, std::time_t timestamp, std::unique_lock<std::recursive_mutex>& lock,
|
2024-06-17 13:25:08 +00:00
|
|
|
const PlayedTimeMap& played_time_map, const INISettingsInterface& custom_attributes_ini)
|
2021-04-17 09:13:58 +00:00
|
|
|
{
|
2022-10-21 11:02:19 +00:00
|
|
|
// don't block UI while scanning
|
|
|
|
lock.unlock();
|
|
|
|
|
2024-05-23 10:55:28 +00:00
|
|
|
DEV_LOG("Scanning '{}'...", path);
|
2021-04-17 09:13:58 +00:00
|
|
|
|
2022-07-11 13:03:29 +00:00
|
|
|
Entry entry;
|
|
|
|
if (!PopulateEntryFromPath(path, &entry))
|
2021-04-17 09:13:58 +00:00
|
|
|
return false;
|
|
|
|
|
|
|
|
entry.path = std::move(path);
|
2022-07-11 13:03:29 +00:00
|
|
|
entry.last_modified_time = timestamp;
|
2021-04-17 09:13:58 +00:00
|
|
|
|
2022-10-23 04:09:54 +00:00
|
|
|
if (s_cache_write_stream || OpenCacheForWriting())
|
2021-04-17 09:13:58 +00:00
|
|
|
{
|
2024-05-23 10:20:16 +00:00
|
|
|
if (!WriteEntryToCache(&entry)) [[unlikely]]
|
2024-05-23 10:55:28 +00:00
|
|
|
WARNING_LOG("Failed to write entry '{}' to cache", entry.path);
|
2021-04-17 09:13:58 +00:00
|
|
|
}
|
|
|
|
|
2024-06-17 13:25:08 +00:00
|
|
|
const auto iter = played_time_map.find(entry.serial);
|
2022-10-21 11:02:19 +00:00
|
|
|
if (iter != played_time_map.end())
|
|
|
|
{
|
|
|
|
entry.last_played_time = iter->second.last_played_time;
|
|
|
|
entry.total_played_time = iter->second.total_played_time;
|
|
|
|
}
|
|
|
|
|
2024-07-06 14:46:00 +00:00
|
|
|
ApplyCustomAttributes(entry.path, &entry, custom_attributes_ini);
|
2024-06-17 13:25:08 +00:00
|
|
|
|
2022-10-21 11:02:19 +00:00
|
|
|
lock.lock();
|
2024-06-17 13:25:08 +00:00
|
|
|
|
|
|
|
// replace if present
|
|
|
|
auto it = std::find_if(s_entries.begin(), s_entries.end(),
|
|
|
|
[&entry](const Entry& existing_entry) { return (existing_entry.path == entry.path); });
|
|
|
|
if (it != s_entries.end())
|
|
|
|
*it = std::move(entry);
|
|
|
|
else
|
|
|
|
s_entries.push_back(std::move(entry));
|
|
|
|
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
|
|
|
bool GameList::RescanCustomAttributesForPath(const std::string& path, const INISettingsInterface& custom_attributes_ini)
|
|
|
|
{
|
|
|
|
FILESYSTEM_STAT_DATA sd;
|
|
|
|
if (!FileSystem::StatFile(path.c_str(), &sd))
|
|
|
|
return false;
|
|
|
|
|
|
|
|
{
|
|
|
|
// cancel if excluded
|
|
|
|
const std::vector<std::string> excluded_paths(Host::GetBaseStringListSetting("GameList", "ExcludedPaths"));
|
|
|
|
if (IsPathExcluded(excluded_paths, path))
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
Entry entry;
|
|
|
|
if (!PopulateEntryFromPath(path, &entry))
|
|
|
|
return false;
|
|
|
|
|
2024-07-06 14:46:00 +00:00
|
|
|
entry.path = path;
|
2024-06-17 13:25:08 +00:00
|
|
|
entry.last_modified_time = sd.ModificationTime;
|
|
|
|
|
|
|
|
const PlayedTimeMap played_time_map(LoadPlayedTimeMap(GetPlayedTimeFile()));
|
|
|
|
const auto iter = played_time_map.find(entry.serial);
|
|
|
|
if (iter != played_time_map.end())
|
|
|
|
{
|
|
|
|
entry.last_played_time = iter->second.last_played_time;
|
|
|
|
entry.total_played_time = iter->second.total_played_time;
|
|
|
|
}
|
|
|
|
|
2024-07-06 14:46:00 +00:00
|
|
|
ApplyCustomAttributes(entry.path, &entry, custom_attributes_ini);
|
2024-06-17 13:25:08 +00:00
|
|
|
|
|
|
|
std::unique_lock lock(s_mutex);
|
|
|
|
|
|
|
|
// replace if present
|
|
|
|
auto it = std::find_if(s_entries.begin(), s_entries.end(),
|
|
|
|
[&entry](const Entry& existing_entry) { return (existing_entry.path == entry.path); });
|
|
|
|
if (it != s_entries.end())
|
|
|
|
*it = std::move(entry);
|
|
|
|
else
|
|
|
|
s_entries.push_back(std::move(entry));
|
|
|
|
|
2021-04-17 09:13:58 +00:00
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
2024-06-17 13:25:08 +00:00
|
|
|
void GameList::ApplyCustomAttributes(const std::string& path, Entry* entry,
|
|
|
|
const INISettingsInterface& custom_attributes_ini)
|
|
|
|
{
|
|
|
|
std::optional<std::string> custom_title = custom_attributes_ini.GetOptionalStringValue(path.c_str(), "Title");
|
|
|
|
if (custom_title.has_value())
|
|
|
|
{
|
|
|
|
entry->title = std::move(custom_title.value());
|
|
|
|
entry->has_custom_title = true;
|
|
|
|
}
|
|
|
|
const std::optional<SmallString> custom_region_str =
|
|
|
|
custom_attributes_ini.GetOptionalSmallStringValue(path.c_str(), "Region");
|
|
|
|
if (custom_region_str.has_value())
|
|
|
|
{
|
|
|
|
const std::optional<DiscRegion> custom_region = Settings::ParseDiscRegionName(custom_region_str.value());
|
|
|
|
if (custom_region.has_value())
|
|
|
|
{
|
|
|
|
entry->region = custom_region.value();
|
|
|
|
entry->has_custom_region = true;
|
|
|
|
}
|
|
|
|
else
|
|
|
|
{
|
|
|
|
WARNING_LOG("Invalid region '{}' in custom attributes for '{}'", custom_region_str.value(), path);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-07-11 13:03:29 +00:00
|
|
|
std::unique_lock<std::recursive_mutex> GameList::GetLock()
|
2019-12-31 06:17:17 +00:00
|
|
|
{
|
2022-07-11 13:03:29 +00:00
|
|
|
return std::unique_lock<std::recursive_mutex>(s_mutex);
|
|
|
|
}
|
2019-12-31 06:17:17 +00:00
|
|
|
|
2022-07-11 13:03:29 +00:00
|
|
|
const GameList::Entry* GameList::GetEntryByIndex(u32 index)
|
|
|
|
{
|
2022-10-21 11:02:19 +00:00
|
|
|
return (index < s_entries.size()) ? &s_entries[index] : nullptr;
|
2019-12-31 06:17:17 +00:00
|
|
|
}
|
|
|
|
|
2024-05-18 03:26:15 +00:00
|
|
|
const GameList::Entry* GameList::GetEntryForPath(std::string_view path)
|
2020-01-24 04:50:44 +00:00
|
|
|
{
|
2024-06-17 13:25:08 +00:00
|
|
|
return GetMutableEntryForPath(path);
|
|
|
|
}
|
|
|
|
|
|
|
|
GameList::Entry* GameList::GetMutableEntryForPath(std::string_view path)
|
|
|
|
{
|
|
|
|
for (Entry& entry : s_entries)
|
2020-01-24 04:50:44 +00:00
|
|
|
{
|
2024-05-18 03:26:15 +00:00
|
|
|
// Use case-insensitive compare on Windows, since it's the same file.
|
|
|
|
#ifdef _WIN32
|
|
|
|
if (StringUtil::EqualNoCase(entry.path, path))
|
|
|
|
return &entry;
|
|
|
|
#else
|
|
|
|
if (entry.path == path)
|
2020-01-24 04:50:44 +00:00
|
|
|
return &entry;
|
2024-05-18 03:26:15 +00:00
|
|
|
#endif
|
2020-01-24 04:50:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
return nullptr;
|
|
|
|
}
|
|
|
|
|
2023-11-28 04:54:25 +00:00
|
|
|
const GameList::Entry* GameList::GetEntryBySerial(std::string_view serial)
|
2020-08-20 11:30:11 +00:00
|
|
|
{
|
2022-10-21 11:02:19 +00:00
|
|
|
for (const Entry& entry : s_entries)
|
2020-08-20 11:30:11 +00:00
|
|
|
{
|
2024-05-18 05:16:54 +00:00
|
|
|
if (entry.serial == serial)
|
2020-08-20 11:30:11 +00:00
|
|
|
return &entry;
|
|
|
|
}
|
|
|
|
|
|
|
|
return nullptr;
|
|
|
|
}
|
|
|
|
|
2023-11-28 04:54:25 +00:00
|
|
|
const GameList::Entry* GameList::GetEntryBySerialAndHash(std::string_view serial, u64 hash)
|
2023-05-15 13:47:22 +00:00
|
|
|
{
|
|
|
|
for (const Entry& entry : s_entries)
|
|
|
|
{
|
|
|
|
if (entry.serial == serial && entry.hash == hash)
|
|
|
|
return &entry;
|
|
|
|
}
|
|
|
|
|
|
|
|
return nullptr;
|
|
|
|
}
|
|
|
|
|
2024-05-18 05:16:54 +00:00
|
|
|
std::vector<const GameList::Entry*> GameList::GetDiscSetMembers(std::string_view disc_set_name,
|
|
|
|
bool sort_by_most_recent)
|
2023-11-28 04:54:25 +00:00
|
|
|
{
|
|
|
|
std::vector<const Entry*> ret;
|
|
|
|
for (const Entry& entry : s_entries)
|
|
|
|
{
|
2024-05-18 05:16:54 +00:00
|
|
|
if (!entry.disc_set_member || disc_set_name != entry.disc_set_name)
|
2023-11-28 04:54:25 +00:00
|
|
|
continue;
|
|
|
|
|
|
|
|
ret.push_back(&entry);
|
|
|
|
}
|
2024-05-18 05:16:54 +00:00
|
|
|
|
|
|
|
if (sort_by_most_recent)
|
|
|
|
{
|
|
|
|
std::sort(ret.begin(), ret.end(), [](const Entry* lhs, const Entry* rhs) {
|
|
|
|
if (lhs->last_played_time == rhs->last_played_time)
|
|
|
|
return (lhs->disc_set_index < rhs->disc_set_index);
|
|
|
|
else
|
|
|
|
return (lhs->last_played_time > rhs->last_played_time);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
else
|
|
|
|
{
|
|
|
|
std::sort(ret.begin(), ret.end(),
|
|
|
|
[](const Entry* lhs, const Entry* rhs) { return (lhs->disc_set_index < rhs->disc_set_index); });
|
|
|
|
}
|
|
|
|
|
2023-11-28 04:54:25 +00:00
|
|
|
return ret;
|
|
|
|
}
|
|
|
|
|
2024-05-18 05:16:54 +00:00
|
|
|
const GameList::Entry* GameList::GetFirstDiscSetMember(std::string_view disc_set_name)
|
|
|
|
{
|
|
|
|
for (const Entry& entry : s_entries)
|
|
|
|
{
|
|
|
|
if (!entry.disc_set_member || disc_set_name != entry.disc_set_name)
|
|
|
|
continue;
|
|
|
|
|
|
|
|
// Disc set should not have been created without the first disc being present.
|
|
|
|
if (entry.disc_set_index == 0)
|
|
|
|
return &entry;
|
|
|
|
}
|
|
|
|
|
|
|
|
return nullptr;
|
|
|
|
}
|
|
|
|
|
2022-07-11 13:03:29 +00:00
|
|
|
u32 GameList::GetEntryCount()
|
2020-05-16 10:01:19 +00:00
|
|
|
{
|
2022-10-21 11:02:19 +00:00
|
|
|
return static_cast<u32>(s_entries.size());
|
2020-05-16 10:01:19 +00:00
|
|
|
}
|
|
|
|
|
2022-07-11 13:03:29 +00:00
|
|
|
void GameList::Refresh(bool invalidate_cache, bool only_cache, ProgressCallback* progress /* = nullptr */)
|
2019-12-31 06:17:17 +00:00
|
|
|
{
|
2023-09-30 11:42:15 +00:00
|
|
|
s_game_list_loaded = true;
|
2021-07-27 08:11:32 +00:00
|
|
|
|
2020-03-12 05:32:19 +00:00
|
|
|
if (!progress)
|
|
|
|
progress = ProgressCallback::NullProgressCallback;
|
|
|
|
|
2020-01-08 03:37:43 +00:00
|
|
|
if (invalidate_cache)
|
|
|
|
DeleteCacheFile();
|
|
|
|
else
|
|
|
|
LoadCache();
|
|
|
|
|
2022-07-11 13:03:29 +00:00
|
|
|
// don't delete the old entries, since the frontend might still access them
|
|
|
|
std::vector<Entry> old_entries;
|
2020-03-12 05:32:19 +00:00
|
|
|
{
|
2022-07-11 13:03:29 +00:00
|
|
|
std::unique_lock lock(s_mutex);
|
2022-10-21 11:02:19 +00:00
|
|
|
old_entries.swap(s_entries);
|
2020-03-12 05:32:19 +00:00
|
|
|
}
|
2020-01-08 03:37:43 +00:00
|
|
|
|
2022-10-23 04:09:54 +00:00
|
|
|
const std::vector<std::string> excluded_paths(Host::GetBaseStringListSetting("GameList", "ExcludedPaths"));
|
|
|
|
const std::vector<std::string> dirs(Host::GetBaseStringListSetting("GameList", "Paths"));
|
2023-10-31 15:32:29 +00:00
|
|
|
std::vector<std::string> recursive_dirs(Host::GetBaseStringListSetting("GameList", "RecursivePaths"));
|
2022-10-21 11:02:19 +00:00
|
|
|
const PlayedTimeMap played_time(LoadPlayedTimeMap(GetPlayedTimeFile()));
|
2024-06-17 13:25:08 +00:00
|
|
|
INISettingsInterface custom_attributes_ini(GetCustomPropertiesFile());
|
|
|
|
custom_attributes_ini.Load();
|
2020-05-16 10:01:19 +00:00
|
|
|
|
2023-10-31 15:32:29 +00:00
|
|
|
#ifdef __ANDROID__
|
|
|
|
recursive_dirs.push_back(Path::Combine(EmuFolders::DataRoot, "games"));
|
|
|
|
#endif
|
|
|
|
|
2022-07-11 13:03:29 +00:00
|
|
|
if (!dirs.empty() || !recursive_dirs.empty())
|
2020-05-16 10:01:19 +00:00
|
|
|
{
|
2022-07-11 13:03:29 +00:00
|
|
|
progress->SetProgressRange(static_cast<u32>(dirs.size() + recursive_dirs.size()));
|
|
|
|
progress->SetProgressValue(0);
|
2020-05-16 10:01:19 +00:00
|
|
|
|
2022-07-11 13:03:29 +00:00
|
|
|
// we manually count it here, because otherwise pop state updates it itself
|
|
|
|
int directory_counter = 0;
|
|
|
|
for (const std::string& dir : dirs)
|
2020-05-16 10:01:19 +00:00
|
|
|
{
|
2022-07-11 13:03:29 +00:00
|
|
|
if (progress->IsCancelled())
|
2020-05-16 10:01:19 +00:00
|
|
|
break;
|
|
|
|
|
2024-06-17 13:25:08 +00:00
|
|
|
ScanDirectory(dir.c_str(), false, only_cache, excluded_paths, played_time, custom_attributes_ini, progress);
|
2022-07-11 13:03:29 +00:00
|
|
|
progress->SetProgressValue(++directory_counter);
|
2020-11-27 07:58:06 +00:00
|
|
|
}
|
2022-07-11 13:03:29 +00:00
|
|
|
for (const std::string& dir : recursive_dirs)
|
2020-05-16 10:01:19 +00:00
|
|
|
{
|
2022-07-11 13:03:29 +00:00
|
|
|
if (progress->IsCancelled())
|
|
|
|
break;
|
2020-08-20 11:30:11 +00:00
|
|
|
|
2024-06-17 13:25:08 +00:00
|
|
|
ScanDirectory(dir.c_str(), true, only_cache, excluded_paths, played_time, custom_attributes_ini, progress);
|
2022-07-11 13:03:29 +00:00
|
|
|
progress->SetProgressValue(++directory_counter);
|
2020-11-27 07:58:06 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-07-11 13:03:29 +00:00
|
|
|
// don't need unused cache entries
|
|
|
|
CloseCacheFileStream();
|
2022-10-23 04:09:54 +00:00
|
|
|
s_cache_map.clear();
|
2024-05-18 05:16:54 +00:00
|
|
|
|
|
|
|
// merge multi-disc games
|
|
|
|
CreateDiscSetEntries(played_time);
|
|
|
|
}
|
|
|
|
|
|
|
|
void GameList::CreateDiscSetEntries(const PlayedTimeMap& played_time_map)
|
|
|
|
{
|
|
|
|
std::unique_lock lock(s_mutex);
|
|
|
|
|
|
|
|
for (size_t i = 0; i < s_entries.size(); i++)
|
|
|
|
{
|
|
|
|
const Entry& entry = s_entries[i];
|
|
|
|
|
|
|
|
// only first discs can create sets
|
|
|
|
if (entry.type != EntryType::Disc || entry.disc_set_member || entry.disc_set_index != 0)
|
|
|
|
continue;
|
|
|
|
|
|
|
|
// already have a disc set by this name?
|
|
|
|
const std::string& disc_set_name = entry.disc_set_name;
|
|
|
|
if (GetEntryForPath(disc_set_name.c_str()))
|
|
|
|
continue;
|
|
|
|
|
|
|
|
const GameDatabase::Entry* dbentry = GameDatabase::GetEntryForSerial(entry.serial);
|
|
|
|
if (!dbentry)
|
|
|
|
continue;
|
|
|
|
|
|
|
|
// need at least two discs for a set
|
|
|
|
bool found_another_disc = false;
|
|
|
|
for (const Entry& other_entry : s_entries)
|
|
|
|
{
|
|
|
|
if (other_entry.type != EntryType::Disc || other_entry.disc_set_member ||
|
|
|
|
other_entry.disc_set_name != disc_set_name || other_entry.disc_set_index == entry.disc_set_index)
|
|
|
|
{
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
found_another_disc = true;
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
if (!found_another_disc)
|
|
|
|
{
|
2024-05-23 10:55:28 +00:00
|
|
|
DEV_LOG("Not creating disc set {}, only one disc found", disc_set_name);
|
2024-05-18 05:16:54 +00:00
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
|
|
|
Entry set_entry;
|
|
|
|
set_entry.type = EntryType::DiscSet;
|
|
|
|
set_entry.region = entry.region;
|
|
|
|
set_entry.path = disc_set_name;
|
|
|
|
set_entry.serial = entry.serial;
|
|
|
|
set_entry.title = entry.disc_set_name;
|
|
|
|
set_entry.genre = entry.developer;
|
|
|
|
set_entry.publisher = entry.publisher;
|
|
|
|
set_entry.developer = entry.developer;
|
|
|
|
set_entry.hash = entry.hash;
|
|
|
|
set_entry.file_size = 0;
|
|
|
|
set_entry.uncompressed_size = 0;
|
|
|
|
set_entry.last_modified_time = entry.last_modified_time;
|
|
|
|
set_entry.last_played_time = 0;
|
|
|
|
set_entry.total_played_time = 0;
|
|
|
|
set_entry.release_date = entry.release_date;
|
|
|
|
set_entry.supported_controllers = entry.supported_controllers;
|
|
|
|
set_entry.min_players = entry.min_players;
|
|
|
|
set_entry.max_players = entry.max_players;
|
|
|
|
set_entry.min_blocks = entry.min_blocks;
|
|
|
|
set_entry.max_blocks = entry.max_blocks;
|
|
|
|
set_entry.compatibility = entry.compatibility;
|
|
|
|
|
|
|
|
// figure out play time for all discs, and sum it
|
|
|
|
// we do this via lookups, rather than the other entries, because of duplicates
|
|
|
|
for (const std::string& set_serial : dbentry->disc_set_serials)
|
|
|
|
{
|
|
|
|
const auto it = played_time_map.find(set_serial);
|
|
|
|
if (it == played_time_map.end())
|
|
|
|
continue;
|
|
|
|
|
|
|
|
set_entry.last_played_time =
|
|
|
|
(set_entry.last_played_time == 0) ?
|
|
|
|
it->second.last_played_time :
|
2024-05-22 12:46:09 +00:00
|
|
|
((it->second.last_played_time != 0) ? std::max(set_entry.last_played_time, it->second.last_played_time) :
|
2024-05-18 05:16:54 +00:00
|
|
|
set_entry.last_played_time);
|
|
|
|
set_entry.total_played_time += it->second.total_played_time;
|
|
|
|
}
|
|
|
|
|
|
|
|
// mark all discs for this set as part of it, so we don't try to add them again, and for filtering
|
|
|
|
u32 num_parts = 0;
|
|
|
|
for (Entry& other_entry : s_entries)
|
|
|
|
{
|
|
|
|
if (other_entry.type != EntryType::Disc || other_entry.disc_set_member ||
|
|
|
|
other_entry.disc_set_name != disc_set_name)
|
|
|
|
{
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
2024-05-23 10:55:28 +00:00
|
|
|
DEV_LOG("Adding {} to disc set {}", Path::GetFileName(other_entry.path), disc_set_name);
|
2024-05-18 05:16:54 +00:00
|
|
|
other_entry.disc_set_member = true;
|
|
|
|
set_entry.last_modified_time = std::min(set_entry.last_modified_time, other_entry.last_modified_time);
|
|
|
|
set_entry.file_size += other_entry.file_size;
|
|
|
|
set_entry.uncompressed_size += other_entry.uncompressed_size;
|
|
|
|
num_parts++;
|
|
|
|
}
|
|
|
|
|
2024-05-23 10:55:28 +00:00
|
|
|
DEV_LOG("Created disc set {} from {} entries", disc_set_name, num_parts);
|
2024-05-18 05:16:54 +00:00
|
|
|
|
|
|
|
// entry is done :)
|
|
|
|
s_entries.push_back(std::move(set_entry));
|
|
|
|
}
|
2021-05-12 17:22:28 +00:00
|
|
|
}
|
|
|
|
|
2022-07-11 13:03:29 +00:00
|
|
|
std::string GameList::GetCoverImagePathForEntry(const Entry* entry)
|
2020-08-20 11:30:11 +00:00
|
|
|
{
|
2022-07-11 13:03:29 +00:00
|
|
|
return GetCoverImagePath(entry->path, entry->serial, entry->title);
|
2020-08-20 11:30:11 +00:00
|
|
|
}
|
2020-09-23 13:43:32 +00:00
|
|
|
|
2024-05-05 10:21:54 +00:00
|
|
|
static std::string GetFullCoverPath(std::string_view filename, std::string_view extension)
|
2021-05-02 04:57:52 +00:00
|
|
|
{
|
2022-07-11 13:03:29 +00:00
|
|
|
return fmt::format("{}" FS_OSPATH_SEPARATOR_STR "{}.{}", EmuFolders::Covers, filename, extension);
|
2021-05-02 04:57:52 +00:00
|
|
|
}
|
|
|
|
|
2022-07-11 13:03:29 +00:00
|
|
|
std::string GameList::GetCoverImagePath(const std::string& path, const std::string& serial, const std::string& title)
|
2020-09-23 13:43:32 +00:00
|
|
|
{
|
2023-10-02 03:33:33 +00:00
|
|
|
static constexpr const std::array extensions = {"jpg", "jpeg", "png", "webp"};
|
2020-09-23 13:43:32 +00:00
|
|
|
|
|
|
|
for (const char* extension : extensions)
|
|
|
|
{
|
2022-07-11 13:03:29 +00:00
|
|
|
// Prioritize lookup by serial (Most specific)
|
|
|
|
if (!serial.empty())
|
2020-12-12 02:23:53 +00:00
|
|
|
{
|
2022-07-11 13:03:29 +00:00
|
|
|
const std::string cover_path(GetFullCoverPath(serial, extension));
|
|
|
|
if (FileSystem::FileExists(cover_path.c_str()))
|
|
|
|
return cover_path;
|
2020-12-12 02:23:53 +00:00
|
|
|
}
|
|
|
|
|
2022-07-11 13:03:29 +00:00
|
|
|
// Try file title (for modded games or specific like above)
|
|
|
|
const std::string_view file_title(Path::GetFileTitle(path));
|
|
|
|
if (!file_title.empty() && title != file_title)
|
2020-09-24 02:22:29 +00:00
|
|
|
{
|
2022-07-11 13:03:29 +00:00
|
|
|
const std::string cover_path(GetFullCoverPath(file_title, extension));
|
|
|
|
if (FileSystem::FileExists(cover_path.c_str()))
|
|
|
|
return cover_path;
|
2020-09-24 02:22:29 +00:00
|
|
|
}
|
2020-09-23 13:43:32 +00:00
|
|
|
|
2022-07-11 13:03:29 +00:00
|
|
|
// Last resort, check the game title
|
|
|
|
if (!title.empty())
|
2020-09-24 02:22:29 +00:00
|
|
|
{
|
2022-07-11 13:03:29 +00:00
|
|
|
const std::string cover_path(GetFullCoverPath(title, extension));
|
|
|
|
if (FileSystem::FileExists(cover_path.c_str()))
|
|
|
|
return cover_path;
|
2020-09-24 02:22:29 +00:00
|
|
|
}
|
2020-09-23 13:43:32 +00:00
|
|
|
}
|
|
|
|
|
2022-07-11 13:03:29 +00:00
|
|
|
return {};
|
2020-09-23 13:43:32 +00:00
|
|
|
}
|
2020-10-03 13:20:48 +00:00
|
|
|
|
2022-09-17 05:51:05 +00:00
|
|
|
std::string GameList::GetNewCoverImagePathForEntry(const Entry* entry, const char* new_filename, bool use_serial)
|
2020-10-03 13:20:48 +00:00
|
|
|
{
|
|
|
|
const char* extension = std::strrchr(new_filename, '.');
|
|
|
|
if (!extension)
|
|
|
|
return {};
|
|
|
|
|
|
|
|
std::string existing_filename = GetCoverImagePathForEntry(entry);
|
|
|
|
if (!existing_filename.empty())
|
|
|
|
{
|
|
|
|
std::string::size_type pos = existing_filename.rfind('.');
|
|
|
|
if (pos != std::string::npos && existing_filename.compare(pos, std::strlen(extension), extension) == 0)
|
|
|
|
return existing_filename;
|
|
|
|
}
|
|
|
|
|
2022-07-11 13:03:29 +00:00
|
|
|
// Check for illegal characters, use serial instead.
|
2022-07-30 11:46:14 +00:00
|
|
|
const std::string sanitized_name(Path::SanitizeFileName(entry->title));
|
2022-07-11 13:03:29 +00:00
|
|
|
|
|
|
|
std::string name;
|
2022-09-17 05:51:05 +00:00
|
|
|
if (sanitized_name != entry->title || use_serial)
|
2022-07-11 13:03:29 +00:00
|
|
|
name = fmt::format("{}{}", entry->serial, extension);
|
|
|
|
else
|
|
|
|
name = fmt::format("{}{}", entry->title, extension);
|
|
|
|
|
2022-09-17 05:51:05 +00:00
|
|
|
return Path::Combine(EmuFolders::Covers, Path::SanitizeFileName(name));
|
2020-10-03 13:20:48 +00:00
|
|
|
}
|
2021-04-17 04:23:47 +00:00
|
|
|
|
2022-07-11 13:03:29 +00:00
|
|
|
size_t GameList::Entry::GetReleaseDateString(char* buffer, size_t buffer_size) const
|
2021-04-17 04:23:47 +00:00
|
|
|
{
|
|
|
|
if (release_date == 0)
|
|
|
|
return StringUtil::Strlcpy(buffer, "Unknown", buffer_size);
|
|
|
|
|
|
|
|
std::time_t date_as_time = static_cast<std::time_t>(release_date);
|
|
|
|
#ifdef _WIN32
|
|
|
|
tm date_tm = {};
|
|
|
|
gmtime_s(&date_tm, &date_as_time);
|
|
|
|
#else
|
|
|
|
tm date_tm = {};
|
|
|
|
gmtime_r(&date_as_time, &date_tm);
|
|
|
|
#endif
|
|
|
|
|
|
|
|
return std::strftime(buffer, buffer_size, "%d %B %Y", &date_tm);
|
|
|
|
}
|
2022-09-09 10:32:21 +00:00
|
|
|
|
2022-10-21 11:02:19 +00:00
|
|
|
std::string GameList::GetPlayedTimeFile()
|
|
|
|
{
|
|
|
|
return Path::Combine(EmuFolders::DataRoot, "playtime.dat");
|
|
|
|
}
|
|
|
|
|
|
|
|
bool GameList::ParsePlayedTimeLine(char* line, std::string& serial, PlayedTimeEntry& entry)
|
|
|
|
{
|
|
|
|
size_t len = std::strlen(line);
|
|
|
|
if (len != (PLAYED_TIME_LINE_LENGTH + 1)) // \n
|
|
|
|
{
|
2024-05-23 10:55:28 +00:00
|
|
|
WARNING_LOG("Malformed line: '{}'", line);
|
2022-10-21 11:02:19 +00:00
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
const std::string_view serial_tok(StringUtil::StripWhitespace(std::string_view(line, PLAYED_TIME_SERIAL_LENGTH)));
|
|
|
|
const std::string_view total_played_time_tok(
|
|
|
|
StringUtil::StripWhitespace(std::string_view(line + PLAYED_TIME_SERIAL_LENGTH + 1, PLAYED_TIME_LAST_TIME_LENGTH)));
|
|
|
|
const std::string_view last_played_time_tok(StringUtil::StripWhitespace(std::string_view(
|
|
|
|
line + PLAYED_TIME_SERIAL_LENGTH + 1 + PLAYED_TIME_LAST_TIME_LENGTH + 1, PLAYED_TIME_TOTAL_TIME_LENGTH)));
|
|
|
|
|
|
|
|
const std::optional<u64> total_played_time(StringUtil::FromChars<u64>(total_played_time_tok));
|
|
|
|
const std::optional<u64> last_played_time(StringUtil::FromChars<u64>(last_played_time_tok));
|
|
|
|
if (serial_tok.empty() || !last_played_time.has_value() || !total_played_time.has_value())
|
|
|
|
{
|
2024-05-23 10:55:28 +00:00
|
|
|
WARNING_LOG("Malformed line: '{}'", line);
|
2022-10-21 11:02:19 +00:00
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
serial = serial_tok;
|
|
|
|
entry.last_played_time = static_cast<std::time_t>(last_played_time.value());
|
|
|
|
entry.total_played_time = static_cast<std::time_t>(total_played_time.value());
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
|
|
|
std::string GameList::MakePlayedTimeLine(const std::string& serial, const PlayedTimeEntry& entry)
|
|
|
|
{
|
|
|
|
return fmt::format("{:<{}} {:<{}} {:<{}}\n", serial, static_cast<unsigned>(PLAYED_TIME_SERIAL_LENGTH),
|
|
|
|
entry.total_played_time, static_cast<unsigned>(PLAYED_TIME_TOTAL_TIME_LENGTH),
|
|
|
|
entry.last_played_time, static_cast<unsigned>(PLAYED_TIME_LAST_TIME_LENGTH));
|
|
|
|
}
|
|
|
|
|
|
|
|
GameList::PlayedTimeMap GameList::LoadPlayedTimeMap(const std::string& path)
|
|
|
|
{
|
|
|
|
PlayedTimeMap ret;
|
|
|
|
|
2022-10-23 04:09:54 +00:00
|
|
|
// Use write mode here, even though we're not writing, so we can lock the file from other updates.
|
|
|
|
auto fp = FileSystem::OpenManagedCFile(path.c_str(), "r+b");
|
2022-10-21 11:02:19 +00:00
|
|
|
|
|
|
|
#ifdef _WIN32
|
|
|
|
// On Windows, the file is implicitly locked.
|
|
|
|
while (!fp && GetLastError() == ERROR_SHARING_VIOLATION)
|
|
|
|
{
|
|
|
|
Sleep(10);
|
|
|
|
fp = FileSystem::OpenManagedCFile(path.c_str(), "r+b");
|
|
|
|
}
|
|
|
|
#endif
|
|
|
|
|
|
|
|
if (fp)
|
|
|
|
{
|
|
|
|
#ifndef _WIN32
|
|
|
|
FileSystem::POSIXLock flock(fp.get());
|
|
|
|
#endif
|
|
|
|
|
|
|
|
char line[256];
|
|
|
|
while (std::fgets(line, sizeof(line), fp.get()))
|
|
|
|
{
|
|
|
|
std::string serial;
|
|
|
|
PlayedTimeEntry entry;
|
|
|
|
if (!ParsePlayedTimeLine(line, serial, entry))
|
|
|
|
continue;
|
|
|
|
|
2023-09-05 11:03:49 +00:00
|
|
|
if (ret.find(serial) != ret.end())
|
2022-10-21 11:02:19 +00:00
|
|
|
{
|
2024-05-25 05:45:17 +00:00
|
|
|
WARNING_LOG("Duplicate entry: '{}'", serial);
|
2022-10-21 11:02:19 +00:00
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
|
|
|
ret.emplace(std::move(serial), entry);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return ret;
|
|
|
|
}
|
|
|
|
|
|
|
|
GameList::PlayedTimeEntry GameList::UpdatePlayedTimeFile(const std::string& path, const std::string& serial,
|
|
|
|
std::time_t last_time, std::time_t add_time)
|
|
|
|
{
|
|
|
|
const PlayedTimeEntry new_entry{last_time, add_time};
|
|
|
|
|
|
|
|
auto fp = FileSystem::OpenManagedCFile(path.c_str(), "r+b");
|
|
|
|
|
|
|
|
#ifdef _WIN32
|
|
|
|
// On Windows, the file is implicitly locked.
|
|
|
|
while (!fp && GetLastError() == ERROR_SHARING_VIOLATION)
|
|
|
|
{
|
|
|
|
Sleep(10);
|
|
|
|
fp = FileSystem::OpenManagedCFile(path.c_str(), "r+b");
|
|
|
|
}
|
|
|
|
#endif
|
|
|
|
|
|
|
|
// Doesn't exist? Create it.
|
|
|
|
if (!fp && errno == ENOENT)
|
|
|
|
fp = FileSystem::OpenManagedCFile(path.c_str(), "w+b");
|
|
|
|
|
|
|
|
if (!fp)
|
|
|
|
{
|
2024-05-23 10:55:28 +00:00
|
|
|
ERROR_LOG("Failed to open '{}' for update.", path);
|
2022-10-21 11:02:19 +00:00
|
|
|
return new_entry;
|
|
|
|
}
|
|
|
|
|
|
|
|
#ifndef _WIN32
|
|
|
|
FileSystem::POSIXLock flock(fp.get());
|
|
|
|
#endif
|
|
|
|
|
|
|
|
for (;;)
|
|
|
|
{
|
|
|
|
char line[256];
|
|
|
|
const s64 line_pos = FileSystem::FTell64(fp.get());
|
|
|
|
if (!std::fgets(line, sizeof(line), fp.get()))
|
|
|
|
break;
|
|
|
|
|
|
|
|
std::string line_serial;
|
|
|
|
PlayedTimeEntry line_entry;
|
|
|
|
if (!ParsePlayedTimeLine(line, line_serial, line_entry))
|
|
|
|
continue;
|
|
|
|
|
|
|
|
if (line_serial != serial)
|
|
|
|
continue;
|
|
|
|
|
|
|
|
// found it!
|
2023-01-11 10:21:27 +00:00
|
|
|
line_entry.last_played_time = (last_time != 0) ? last_time : 0;
|
|
|
|
line_entry.total_played_time = (last_time != 0) ? (line_entry.total_played_time + add_time) : 0;
|
2022-10-21 11:02:19 +00:00
|
|
|
|
|
|
|
std::string new_line(MakePlayedTimeLine(serial, line_entry));
|
|
|
|
if (FileSystem::FSeek64(fp.get(), line_pos, SEEK_SET) != 0 ||
|
|
|
|
std::fwrite(new_line.data(), new_line.length(), 1, fp.get()) != 1)
|
|
|
|
{
|
2024-05-25 05:45:17 +00:00
|
|
|
ERROR_LOG("Failed to update '{}'.", path);
|
2022-10-21 11:02:19 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
return line_entry;
|
|
|
|
}
|
|
|
|
|
2023-01-11 10:21:27 +00:00
|
|
|
if (last_time != 0)
|
2022-10-21 11:02:19 +00:00
|
|
|
{
|
2023-01-11 10:21:27 +00:00
|
|
|
// new entry.
|
|
|
|
std::string new_line(MakePlayedTimeLine(serial, new_entry));
|
|
|
|
if (FileSystem::FSeek64(fp.get(), 0, SEEK_END) != 0 ||
|
|
|
|
std::fwrite(new_line.data(), new_line.length(), 1, fp.get()) != 1)
|
|
|
|
{
|
2024-05-25 05:45:17 +00:00
|
|
|
ERROR_LOG("Failed to write '{}'.", path);
|
2023-01-11 10:21:27 +00:00
|
|
|
}
|
2022-10-21 11:02:19 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
return new_entry;
|
|
|
|
}
|
|
|
|
|
|
|
|
void GameList::AddPlayedTimeForSerial(const std::string& serial, std::time_t last_time, std::time_t add_time)
|
|
|
|
{
|
|
|
|
if (serial.empty())
|
|
|
|
return;
|
|
|
|
|
|
|
|
const PlayedTimeEntry pt(UpdatePlayedTimeFile(GetPlayedTimeFile(), serial, last_time, add_time));
|
2024-05-23 10:55:28 +00:00
|
|
|
VERBOSE_LOG("Add {} seconds play time to {} -> now {}", static_cast<unsigned>(add_time), serial.c_str(),
|
|
|
|
static_cast<unsigned>(pt.total_played_time));
|
2022-10-21 11:02:19 +00:00
|
|
|
|
|
|
|
std::unique_lock<std::recursive_mutex> lock(s_mutex);
|
|
|
|
for (GameList::Entry& entry : s_entries)
|
|
|
|
{
|
|
|
|
if (entry.serial != serial)
|
|
|
|
continue;
|
|
|
|
|
|
|
|
entry.last_played_time = pt.last_played_time;
|
|
|
|
entry.total_played_time = pt.total_played_time;
|
|
|
|
}
|
2024-05-22 12:46:09 +00:00
|
|
|
|
|
|
|
// We don't need to update the disc sets if we're not running Big Picture, because Qt refreshes on system destory,
|
|
|
|
// which causes the disc set entries to get recreated.
|
|
|
|
if (FullscreenUI::IsInitialized())
|
|
|
|
{
|
|
|
|
const GameDatabase::Entry* dbentry = GameDatabase::GetEntryForSerial(serial);
|
|
|
|
if (dbentry && !dbentry->disc_set_serials.empty())
|
|
|
|
{
|
|
|
|
for (GameList::Entry& entry : s_entries)
|
|
|
|
{
|
|
|
|
if (entry.type != EntryType::DiscSet || entry.path != dbentry->disc_set_name)
|
|
|
|
continue;
|
|
|
|
|
|
|
|
entry.last_played_time = 0;
|
|
|
|
entry.total_played_time = 0;
|
|
|
|
|
|
|
|
// We shouldn't ever have duplicates for disc sets, so this should be fine.
|
|
|
|
const PlayedTimeMap ptm = LoadPlayedTimeMap(GetPlayedTimeFile());
|
|
|
|
for (const std::string& dsserial : dbentry->disc_set_serials)
|
|
|
|
{
|
|
|
|
const auto it = ptm.find(dsserial);
|
|
|
|
if (it == ptm.end())
|
|
|
|
continue;
|
|
|
|
|
|
|
|
entry.last_played_time =
|
|
|
|
(entry.last_played_time == 0) ?
|
|
|
|
it->second.last_played_time :
|
|
|
|
((it->second.last_played_time != 0) ? std::max(entry.last_played_time, it->second.last_played_time) :
|
|
|
|
entry.last_played_time);
|
|
|
|
entry.total_played_time += it->second.total_played_time;
|
|
|
|
}
|
|
|
|
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2022-10-21 11:02:19 +00:00
|
|
|
}
|
|
|
|
|
2023-01-11 10:21:27 +00:00
|
|
|
void GameList::ClearPlayedTimeForSerial(const std::string& serial)
|
|
|
|
{
|
|
|
|
if (serial.empty())
|
|
|
|
return;
|
|
|
|
|
|
|
|
UpdatePlayedTimeFile(GetPlayedTimeFile(), serial, 0, 0);
|
|
|
|
|
|
|
|
std::unique_lock<std::recursive_mutex> lock(s_mutex);
|
|
|
|
for (GameList::Entry& entry : s_entries)
|
|
|
|
{
|
|
|
|
if (entry.serial != serial)
|
|
|
|
continue;
|
|
|
|
|
|
|
|
entry.last_played_time = 0;
|
|
|
|
entry.total_played_time = 0;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-10-23 04:09:54 +00:00
|
|
|
std::time_t GameList::GetCachedPlayedTimeForSerial(const std::string& serial)
|
|
|
|
{
|
|
|
|
if (serial.empty())
|
|
|
|
return 0;
|
|
|
|
|
|
|
|
std::unique_lock<std::recursive_mutex> lock(s_mutex);
|
|
|
|
for (GameList::Entry& entry : s_entries)
|
|
|
|
{
|
|
|
|
if (entry.serial == serial)
|
|
|
|
return entry.total_played_time;
|
|
|
|
}
|
|
|
|
|
|
|
|
return 0;
|
|
|
|
}
|
|
|
|
|
2022-10-21 11:02:19 +00:00
|
|
|
TinyString GameList::FormatTimestamp(std::time_t timestamp)
|
|
|
|
{
|
|
|
|
TinyString ret;
|
|
|
|
|
|
|
|
if (timestamp == 0)
|
|
|
|
{
|
2023-08-19 14:08:03 +00:00
|
|
|
ret = TRANSLATE("GameList", "Never");
|
2022-10-21 11:02:19 +00:00
|
|
|
}
|
|
|
|
else
|
|
|
|
{
|
|
|
|
struct tm ctime = {};
|
|
|
|
struct tm ttime = {};
|
|
|
|
const std::time_t ctimestamp = std::time(nullptr);
|
|
|
|
#ifdef _MSC_VER
|
|
|
|
localtime_s(&ctime, &ctimestamp);
|
|
|
|
localtime_s(&ttime, ×tamp);
|
|
|
|
#else
|
|
|
|
localtime_r(&ctimestamp, &ctime);
|
|
|
|
localtime_r(×tamp, &ttime);
|
|
|
|
#endif
|
|
|
|
|
|
|
|
if (ctime.tm_year == ttime.tm_year && ctime.tm_yday == ttime.tm_yday)
|
|
|
|
{
|
2023-08-19 14:08:03 +00:00
|
|
|
ret = TRANSLATE("GameList", "Today");
|
2022-10-21 11:02:19 +00:00
|
|
|
}
|
|
|
|
else if ((ctime.tm_year == ttime.tm_year && ctime.tm_yday == (ttime.tm_yday + 1)) ||
|
|
|
|
(ctime.tm_yday == 0 && (ctime.tm_year - 1) == ttime.tm_year))
|
|
|
|
{
|
2023-08-19 14:08:03 +00:00
|
|
|
ret = TRANSLATE("GameList", "Yesterday");
|
2022-10-21 11:02:19 +00:00
|
|
|
}
|
|
|
|
else
|
|
|
|
{
|
|
|
|
char buf[128];
|
|
|
|
std::strftime(buf, std::size(buf), "%x", &ttime);
|
2023-09-20 13:49:14 +00:00
|
|
|
ret.assign(buf);
|
2022-10-21 11:02:19 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return ret;
|
|
|
|
}
|
|
|
|
|
2022-10-23 04:09:54 +00:00
|
|
|
TinyString GameList::FormatTimespan(std::time_t timespan, bool long_format)
|
2022-10-21 11:02:19 +00:00
|
|
|
{
|
|
|
|
const u32 hours = static_cast<u32>(timespan / 3600);
|
|
|
|
const u32 minutes = static_cast<u32>((timespan % 3600) / 60);
|
|
|
|
const u32 seconds = static_cast<u32>((timespan % 3600) % 60);
|
|
|
|
|
|
|
|
TinyString ret;
|
2022-10-23 04:09:54 +00:00
|
|
|
if (!long_format)
|
|
|
|
{
|
|
|
|
if (hours >= 100)
|
2023-12-13 11:06:15 +00:00
|
|
|
ret.format(TRANSLATE_FS("GameList", "{}h {}m"), hours, minutes);
|
2022-10-23 04:09:54 +00:00
|
|
|
else if (hours > 0)
|
2023-12-13 11:06:15 +00:00
|
|
|
ret.format(TRANSLATE_FS("GameList", "{}h {}m {}s"), hours, minutes, seconds);
|
2022-10-23 04:09:54 +00:00
|
|
|
else if (minutes > 0)
|
2023-12-13 11:06:15 +00:00
|
|
|
ret.format(TRANSLATE_FS("GameList", "{}m {}s"), minutes, seconds);
|
2022-10-23 04:09:54 +00:00
|
|
|
else if (seconds > 0)
|
2023-12-13 11:06:15 +00:00
|
|
|
ret.format(TRANSLATE_FS("GameList", "{}s"), seconds);
|
2022-10-23 04:09:54 +00:00
|
|
|
else
|
2023-08-19 14:08:03 +00:00
|
|
|
ret = TRANSLATE_SV("GameList", "None");
|
2022-10-23 04:09:54 +00:00
|
|
|
}
|
2022-10-21 11:02:19 +00:00
|
|
|
else
|
2022-10-23 04:09:54 +00:00
|
|
|
{
|
|
|
|
if (hours > 0)
|
2024-05-17 03:07:23 +00:00
|
|
|
ret.assign(TRANSLATE_PLURAL_STR("GameList", "%n hours", "", hours));
|
2022-10-23 04:09:54 +00:00
|
|
|
else
|
2024-05-12 15:46:45 +00:00
|
|
|
ret.assign(TRANSLATE_PLURAL_STR("GameList", "%n minutes", "", minutes));
|
2022-10-23 04:09:54 +00:00
|
|
|
}
|
2022-10-21 11:02:19 +00:00
|
|
|
|
|
|
|
return ret;
|
|
|
|
}
|
|
|
|
|
2023-08-23 08:12:10 +00:00
|
|
|
std::vector<std::pair<std::string, const GameList::Entry*>>
|
2023-09-05 11:07:20 +00:00
|
|
|
GameList::GetMatchingEntriesForSerial(const std::span<const std::string> serials)
|
2023-08-23 08:12:10 +00:00
|
|
|
{
|
|
|
|
std::vector<std::pair<std::string, const GameList::Entry*>> ret;
|
|
|
|
ret.reserve(serials.size());
|
|
|
|
|
|
|
|
for (const std::string& serial : serials)
|
|
|
|
{
|
|
|
|
const Entry* matching_entry = nullptr;
|
|
|
|
bool has_multiple_entries = false;
|
|
|
|
|
|
|
|
for (const Entry& entry : s_entries)
|
|
|
|
{
|
|
|
|
if (entry.serial != serial)
|
|
|
|
continue;
|
|
|
|
|
|
|
|
if (!matching_entry)
|
|
|
|
matching_entry = &entry;
|
|
|
|
else
|
|
|
|
has_multiple_entries = true;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!matching_entry)
|
|
|
|
continue;
|
|
|
|
|
|
|
|
if (!has_multiple_entries)
|
|
|
|
{
|
|
|
|
ret.emplace_back(matching_entry->title, matching_entry);
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Have to add all matching files.
|
|
|
|
for (const Entry& entry : s_entries)
|
|
|
|
{
|
|
|
|
if (entry.serial != serial)
|
|
|
|
continue;
|
|
|
|
|
|
|
|
ret.emplace_back(Path::GetFileName(entry.path), &entry);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return ret;
|
|
|
|
}
|
|
|
|
|
2022-09-17 05:51:05 +00:00
|
|
|
bool GameList::DownloadCovers(const std::vector<std::string>& url_templates, bool use_serial,
|
|
|
|
ProgressCallback* progress, std::function<void(const Entry*, std::string)> save_callback)
|
2022-09-09 10:32:21 +00:00
|
|
|
{
|
|
|
|
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<std::pair<std::string, std::string>> download_urls;
|
|
|
|
{
|
|
|
|
std::unique_lock lock(s_mutex);
|
2022-10-21 11:02:19 +00:00
|
|
|
for (const GameList::Entry& entry : s_entries)
|
2022-09-09 10:32:21 +00:00
|
|
|
{
|
|
|
|
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)
|
2024-03-13 09:39:10 +00:00
|
|
|
StringUtil::ReplaceAll(&url, "${title}", Path::URLEncode(entry.title));
|
2022-09-09 10:32:21 +00:00
|
|
|
if (has_file_title)
|
|
|
|
{
|
|
|
|
std::string display_name(FileSystem::GetDisplayNameFromPath(entry.path));
|
2024-03-13 09:39:10 +00:00
|
|
|
StringUtil::ReplaceAll(&url, "${filetitle}", Path::URLEncode(Path::GetFileTitle(display_name)));
|
2022-09-09 10:32:21 +00:00
|
|
|
}
|
|
|
|
if (has_serial)
|
2024-03-13 09:39:10 +00:00
|
|
|
StringUtil::ReplaceAll(&url, "${serial}", Path::URLEncode(entry.serial));
|
2022-09-09 10:32:21 +00:00
|
|
|
|
|
|
|
download_urls.emplace_back(entry.path, std::move(url));
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if (download_urls.empty())
|
|
|
|
{
|
|
|
|
progress->DisplayError("No URLs to download enumerated.");
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
2023-11-24 05:54:43 +00:00
|
|
|
std::unique_ptr<HTTPDownloader> downloader(HTTPDownloader::Create(Host::GetHTTPUserAgent()));
|
2022-09-09 10:32:21 +00:00
|
|
|
if (!downloader)
|
|
|
|
{
|
|
|
|
progress->DisplayError("Failed to create HTTP downloader.");
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
progress->SetCancellable(true);
|
|
|
|
progress->SetProgressRange(static_cast<u32>(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);
|
2024-05-18 03:26:15 +00:00
|
|
|
const GameList::Entry* entry = GetEntryForPath(entry_path);
|
2022-09-09 10:32:21 +00:00
|
|
|
if (!entry || !GetCoverImagePathForEntry(entry).empty())
|
|
|
|
{
|
|
|
|
progress->IncrementProgressValue();
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
|
|
|
progress->SetFormattedStatusText("Downloading cover for %s...", entry->title.c_str());
|
|
|
|
}
|
|
|
|
|
|
|
|
// we could actually do a few in parallel here...
|
2024-03-13 09:39:10 +00:00
|
|
|
std::string filename = Path::URLDecode(url);
|
2022-09-17 05:51:05 +00:00
|
|
|
downloader->CreateRequest(
|
2022-10-21 11:02:19 +00:00
|
|
|
std::move(url), [use_serial, &save_callback, entry_path = std::move(entry_path), filename = std::move(filename)](
|
2023-11-24 05:54:43 +00:00
|
|
|
s32 status_code, const std::string& content_type, HTTPDownloader::Request::Data data) {
|
2023-11-06 09:59:02 +00:00
|
|
|
if (status_code != HTTPDownloader::HTTP_STATUS_OK || data.empty())
|
2022-09-17 05:51:05 +00:00
|
|
|
return;
|
|
|
|
|
|
|
|
std::unique_lock lock(s_mutex);
|
2024-05-18 03:26:15 +00:00
|
|
|
const GameList::Entry* entry = GetEntryForPath(entry_path);
|
2022-09-17 05:51:05 +00:00
|
|
|
if (!entry || !GetCoverImagePathForEntry(entry).empty())
|
|
|
|
return;
|
|
|
|
|
2022-09-18 03:00:55 +00:00
|
|
|
// 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;
|
2023-11-06 09:59:02 +00:00
|
|
|
std::string content_type_extension(HTTPDownloader::GetExtensionForContentType(content_type));
|
2022-09-18 03:00:55 +00:00
|
|
|
|
|
|
|
// 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));
|
2022-09-17 05:51:05 +00:00
|
|
|
if (write_path.empty())
|
|
|
|
return;
|
|
|
|
|
2022-09-18 03:00:55 +00:00
|
|
|
if (FileSystem::WriteBinaryFile(write_path.c_str(), data.data(), data.size()) && save_callback)
|
2022-09-17 05:51:05 +00:00
|
|
|
save_callback(entry, std::move(write_path));
|
|
|
|
});
|
2022-09-09 10:32:21 +00:00
|
|
|
downloader->WaitForAllRequests();
|
|
|
|
progress->IncrementProgressValue();
|
|
|
|
}
|
|
|
|
|
|
|
|
return true;
|
|
|
|
}
|
2024-06-17 13:25:08 +00:00
|
|
|
|
|
|
|
std::string GameList::GetCustomPropertiesFile()
|
|
|
|
{
|
|
|
|
return Path::Combine(EmuFolders::DataRoot, "custom_properties.ini");
|
|
|
|
}
|
|
|
|
|
|
|
|
void GameList::SaveCustomTitleForPath(const std::string& path, const std::string& custom_title)
|
|
|
|
{
|
|
|
|
INISettingsInterface custom_attributes_ini(GetCustomPropertiesFile());
|
|
|
|
custom_attributes_ini.Load();
|
|
|
|
|
|
|
|
if (!custom_title.empty())
|
|
|
|
{
|
|
|
|
custom_attributes_ini.SetStringValue(path.c_str(), "Title", custom_title.c_str());
|
|
|
|
}
|
|
|
|
else
|
|
|
|
{
|
|
|
|
custom_attributes_ini.DeleteValue(path.c_str(), "Title");
|
|
|
|
custom_attributes_ini.RemoveEmptySections();
|
|
|
|
}
|
|
|
|
|
|
|
|
Error error;
|
|
|
|
if (!custom_attributes_ini.Save(&error))
|
|
|
|
{
|
|
|
|
ERROR_LOG("Failed to save custom attributes: {}", error.GetDescription());
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!custom_title.empty())
|
|
|
|
{
|
|
|
|
// Can skip the rescan and just update the value directly.
|
|
|
|
auto lock = GetLock();
|
|
|
|
Entry* entry = GetMutableEntryForPath(path);
|
|
|
|
if (entry)
|
|
|
|
{
|
|
|
|
entry->title = custom_title;
|
|
|
|
entry->has_custom_title = true;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
else
|
|
|
|
{
|
|
|
|
// Let the cache update by rescanning. Only need to do this on deletion, to get the original value.
|
|
|
|
RescanCustomAttributesForPath(path, custom_attributes_ini);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
void GameList::SaveCustomRegionForPath(const std::string& path, const std::optional<DiscRegion> custom_region)
|
|
|
|
{
|
|
|
|
INISettingsInterface custom_attributes_ini(GetCustomPropertiesFile());
|
|
|
|
custom_attributes_ini.Load();
|
|
|
|
|
|
|
|
if (custom_region.has_value())
|
|
|
|
{
|
|
|
|
custom_attributes_ini.SetStringValue(path.c_str(), "Region", Settings::GetDiscRegionName(custom_region.value()));
|
|
|
|
}
|
|
|
|
else
|
|
|
|
{
|
|
|
|
custom_attributes_ini.DeleteValue(path.c_str(), "Region");
|
|
|
|
custom_attributes_ini.RemoveEmptySections();
|
|
|
|
}
|
|
|
|
|
|
|
|
Error error;
|
|
|
|
if (!custom_attributes_ini.Save(&error))
|
|
|
|
{
|
|
|
|
ERROR_LOG("Failed to save custom attributes: {}", error.GetDescription());
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (custom_region.has_value())
|
|
|
|
{
|
|
|
|
// Can skip the rescan and just update the value directly.
|
|
|
|
auto lock = GetLock();
|
|
|
|
Entry* entry = GetMutableEntryForPath(path);
|
|
|
|
if (entry)
|
|
|
|
{
|
|
|
|
entry->region = custom_region.value();
|
|
|
|
entry->has_custom_region = true;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
else
|
|
|
|
{
|
|
|
|
// Let the cache update by rescanning. Only need to do this on deletion, to get the original value.
|
|
|
|
RescanCustomAttributesForPath(path, custom_attributes_ini);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
std::string GameList::GetCustomTitleForPath(const std::string_view path)
|
|
|
|
{
|
|
|
|
std::string ret;
|
|
|
|
|
|
|
|
std::unique_lock lock(s_mutex);
|
|
|
|
const GameList::Entry* entry = GetEntryForPath(path);
|
|
|
|
if (entry && entry->has_custom_title)
|
|
|
|
ret = entry->title;
|
|
|
|
|
|
|
|
return ret;
|
|
|
|
}
|
|
|
|
|
|
|
|
std::optional<DiscRegion> GameList::GetCustomRegionForPath(const std::string_view path)
|
|
|
|
{
|
|
|
|
const GameList::Entry* entry = GetEntryForPath(path);
|
|
|
|
if (entry && entry->has_custom_region)
|
|
|
|
return entry->region;
|
|
|
|
else
|
|
|
|
return std::nullopt;
|
|
|
|
}
|
2024-07-11 07:15:57 +00:00
|
|
|
|
2024-07-14 02:56:37 +00:00
|
|
|
static constexpr const char MEMCARD_TIMESTAMP_CACHE_SIGNATURE[] = {'M', 'C', 'D', 'I', 'C', 'N', '0', '3'};
|
2024-07-11 07:15:57 +00:00
|
|
|
|
|
|
|
FileSystem::ManagedCFilePtr GameList::OpenMemoryCardTimestampCache(bool for_write)
|
|
|
|
{
|
|
|
|
const std::string filename = Path::Combine(EmuFolders::Cache, "memcard_icons.cache");
|
|
|
|
const FileSystem::FileShareMode share_mode =
|
|
|
|
for_write ? FileSystem::FileShareMode::DenyReadWrite : FileSystem::FileShareMode::DenyWrite;
|
2024-07-13 08:23:53 +00:00
|
|
|
#ifdef _WIN32
|
|
|
|
const char* mode = for_write ? "r+b" : "rb";
|
|
|
|
#else
|
|
|
|
// Always open read/write on Linux, since we need it for flock().
|
|
|
|
const char* mode = "r+b";
|
|
|
|
#endif
|
|
|
|
|
2024-07-11 07:15:57 +00:00
|
|
|
FileSystem::ManagedCFilePtr fp = FileSystem::OpenManagedSharedCFile(filename.c_str(), mode, share_mode, nullptr);
|
|
|
|
if (fp)
|
|
|
|
return fp;
|
|
|
|
|
|
|
|
// Doesn't exist? Create it.
|
|
|
|
if (errno == ENOENT)
|
|
|
|
{
|
|
|
|
if (!for_write)
|
|
|
|
return nullptr;
|
|
|
|
|
|
|
|
mode = "w+b";
|
|
|
|
fp = FileSystem::OpenManagedSharedCFile(filename.c_str(), mode, share_mode, nullptr);
|
|
|
|
if (fp)
|
|
|
|
return fp;
|
|
|
|
}
|
|
|
|
|
|
|
|
// If there's a sharing violation, try again for 100ms.
|
|
|
|
if (errno != EACCES)
|
|
|
|
return nullptr;
|
|
|
|
|
|
|
|
Common::Timer timer;
|
|
|
|
while (timer.GetTimeMilliseconds() <= 100.0f)
|
|
|
|
{
|
|
|
|
fp = FileSystem::OpenManagedSharedCFile(filename.c_str(), mode, share_mode, nullptr);
|
|
|
|
if (fp)
|
|
|
|
return fp;
|
|
|
|
|
|
|
|
if (errno != EACCES)
|
|
|
|
return nullptr;
|
|
|
|
}
|
|
|
|
|
|
|
|
ERROR_LOG("Timed out while trying to open memory card cache file.");
|
|
|
|
return nullptr;
|
|
|
|
}
|
|
|
|
|
|
|
|
void GameList::ReloadMemcardTimestampCache()
|
|
|
|
{
|
|
|
|
s_memcard_timestamp_cache_entries.clear();
|
|
|
|
|
|
|
|
FileSystem::ManagedCFilePtr fp = OpenMemoryCardTimestampCache(false);
|
|
|
|
if (!fp)
|
|
|
|
return;
|
|
|
|
|
|
|
|
#ifndef _WIN32
|
|
|
|
FileSystem::POSIXLock lock(fp.get());
|
|
|
|
#endif
|
|
|
|
|
|
|
|
const s64 file_size = FileSystem::FSize64(fp.get());
|
|
|
|
if (file_size < static_cast<s64>(sizeof(MEMCARD_TIMESTAMP_CACHE_SIGNATURE)))
|
|
|
|
return;
|
|
|
|
|
|
|
|
const size_t count =
|
|
|
|
(static_cast<size_t>(file_size) - sizeof(MEMCARD_TIMESTAMP_CACHE_SIGNATURE)) / sizeof(MemcardTimestampCacheEntry);
|
|
|
|
if (count <= 0)
|
|
|
|
return;
|
|
|
|
|
|
|
|
char signature[sizeof(MEMCARD_TIMESTAMP_CACHE_SIGNATURE)];
|
|
|
|
if (std::fread(signature, sizeof(signature), 1, fp.get()) != 1 ||
|
|
|
|
std::memcmp(signature, MEMCARD_TIMESTAMP_CACHE_SIGNATURE, sizeof(signature)) != 0)
|
|
|
|
{
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
s_memcard_timestamp_cache_entries.resize(static_cast<size_t>(count));
|
|
|
|
if (std::fread(s_memcard_timestamp_cache_entries.data(), sizeof(MemcardTimestampCacheEntry),
|
|
|
|
s_memcard_timestamp_cache_entries.size(), fp.get()) != s_memcard_timestamp_cache_entries.size())
|
|
|
|
{
|
|
|
|
s_memcard_timestamp_cache_entries = {};
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Just in case.
|
|
|
|
for (MemcardTimestampCacheEntry& entry : s_memcard_timestamp_cache_entries)
|
|
|
|
entry.serial[sizeof(entry.serial) - 1] = 0;
|
|
|
|
}
|
|
|
|
|
|
|
|
std::string GameList::GetGameIconPath(std::string_view serial, std::string_view path)
|
|
|
|
{
|
|
|
|
std::string ret;
|
|
|
|
|
|
|
|
if (serial.empty())
|
|
|
|
return ret;
|
|
|
|
|
|
|
|
// might exist already, or the user used a custom icon
|
|
|
|
ret = Path::Combine(EmuFolders::GameIcons, TinyString::from_format("{}.png", serial));
|
|
|
|
if (FileSystem::FileExists(ret.c_str()))
|
|
|
|
return ret;
|
|
|
|
|
|
|
|
MemoryCardType type;
|
|
|
|
std::string memcard_path = System::GetGameMemoryCardPath(serial, path, 0, &type);
|
|
|
|
FILESYSTEM_STAT_DATA memcard_sd;
|
|
|
|
if (memcard_path.empty() || type == MemoryCardType::Shared ||
|
|
|
|
!FileSystem::StatFile(memcard_path.c_str(), &memcard_sd))
|
|
|
|
{
|
|
|
|
ret = {};
|
|
|
|
return ret;
|
|
|
|
}
|
|
|
|
|
|
|
|
const s64 timestamp = memcard_sd.ModificationTime;
|
|
|
|
TinyString index_serial;
|
|
|
|
index_serial.assign(
|
|
|
|
serial.substr(0, std::min<size_t>(serial.length(), MemcardTimestampCacheEntry::MAX_SERIAL_LENGTH - 1)));
|
|
|
|
|
|
|
|
MemcardTimestampCacheEntry* serial_entry = nullptr;
|
|
|
|
for (MemcardTimestampCacheEntry& entry : s_memcard_timestamp_cache_entries)
|
|
|
|
{
|
|
|
|
if (StringUtil::EqualNoCase(index_serial, entry.serial))
|
|
|
|
{
|
2024-07-14 02:56:37 +00:00
|
|
|
// user might've deleted the file, so re-extract it if so
|
|
|
|
// otherwise, card hasn't changed, still no icon
|
|
|
|
if (entry.memcard_timestamp == timestamp && !entry.icon_was_extracted)
|
2024-07-11 07:15:57 +00:00
|
|
|
{
|
|
|
|
ret = {};
|
|
|
|
return ret;
|
|
|
|
}
|
|
|
|
|
|
|
|
serial_entry = &entry;
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!serial_entry)
|
|
|
|
{
|
|
|
|
serial_entry = &s_memcard_timestamp_cache_entries.emplace_back();
|
|
|
|
std::memset(serial_entry, 0, sizeof(MemcardTimestampCacheEntry));
|
|
|
|
}
|
|
|
|
|
|
|
|
serial_entry->memcard_timestamp = timestamp;
|
2024-07-14 02:56:37 +00:00
|
|
|
serial_entry->icon_was_extracted = false;
|
2024-07-11 07:15:57 +00:00
|
|
|
StringUtil::Strlcpy(serial_entry->serial, index_serial.view(), sizeof(serial_entry->serial));
|
|
|
|
|
|
|
|
// Try extracting an icon.
|
|
|
|
MemoryCardImage::DataArray data;
|
|
|
|
if (MemoryCardImage::LoadFromFile(&data, memcard_path.c_str()))
|
|
|
|
{
|
|
|
|
std::vector<MemoryCardImage::FileInfo> files = MemoryCardImage::EnumerateFiles(data, false);
|
|
|
|
if (!files.empty())
|
|
|
|
{
|
|
|
|
const MemoryCardImage::FileInfo& fi = files.front();
|
|
|
|
if (!fi.icon_frames.empty())
|
|
|
|
{
|
|
|
|
INFO_LOG("Extracting memory card icon from {} ({}) to {}", fi.filename, Path::GetFileTitle(memcard_path),
|
|
|
|
Path::GetFileTitle(ret));
|
|
|
|
|
|
|
|
RGBA8Image image(MemoryCardImage::ICON_WIDTH, MemoryCardImage::ICON_HEIGHT);
|
|
|
|
std::memcpy(image.GetPixels(), &fi.icon_frames.front().pixels,
|
|
|
|
MemoryCardImage::ICON_WIDTH * MemoryCardImage::ICON_HEIGHT * sizeof(u32));
|
2024-07-14 02:56:37 +00:00
|
|
|
serial_entry->icon_was_extracted = image.SaveToFile(ret.c_str());
|
|
|
|
if (!serial_entry->icon_was_extracted)
|
2024-07-11 07:15:57 +00:00
|
|
|
{
|
|
|
|
ERROR_LOG("Failed to save memory card icon to {}.", ret);
|
|
|
|
ret = {};
|
|
|
|
return ret;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
UpdateMemcardTimestampCache(*serial_entry);
|
|
|
|
return ret;
|
|
|
|
}
|
|
|
|
|
|
|
|
bool GameList::UpdateMemcardTimestampCache(const MemcardTimestampCacheEntry& entry)
|
|
|
|
{
|
|
|
|
FileSystem::ManagedCFilePtr fp = OpenMemoryCardTimestampCache(true);
|
|
|
|
if (!fp)
|
|
|
|
return false;
|
|
|
|
|
|
|
|
#ifndef _WIN32
|
|
|
|
FileSystem::POSIXLock lock(fp.get());
|
|
|
|
#endif
|
|
|
|
|
|
|
|
// check signature, write it if it's non-existent or invalid
|
|
|
|
char signature[sizeof(MEMCARD_TIMESTAMP_CACHE_SIGNATURE)];
|
|
|
|
if (std::fread(signature, sizeof(signature), 1, fp.get()) != 1 ||
|
|
|
|
std::memcmp(signature, MEMCARD_TIMESTAMP_CACHE_SIGNATURE, sizeof(signature)) != 0)
|
|
|
|
{
|
|
|
|
if (!FileSystem::FTruncate64(fp.get(), 0) || FileSystem::FSeek64(fp.get(), 0, SEEK_SET) != 0 ||
|
|
|
|
std::fwrite(MEMCARD_TIMESTAMP_CACHE_SIGNATURE, sizeof(MEMCARD_TIMESTAMP_CACHE_SIGNATURE), 1, fp.get()) != 1)
|
|
|
|
{
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// need to seek to switch from read->write?
|
|
|
|
s64 current_pos = sizeof(MEMCARD_TIMESTAMP_CACHE_SIGNATURE);
|
|
|
|
if (FileSystem::FSeek64(fp.get(), current_pos, SEEK_SET) != 0)
|
|
|
|
return false;
|
|
|
|
|
|
|
|
for (;;)
|
|
|
|
{
|
|
|
|
MemcardTimestampCacheEntry existing_entry;
|
|
|
|
if (std::fread(&existing_entry, sizeof(existing_entry), 1, fp.get()) != 1)
|
|
|
|
break;
|
|
|
|
|
|
|
|
existing_entry.serial[sizeof(existing_entry.serial) - 1] = 0;
|
|
|
|
if (!StringUtil::EqualNoCase(existing_entry.serial, entry.serial))
|
|
|
|
{
|
|
|
|
current_pos += sizeof(existing_entry);
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
|
|
|
// found it here, so overwrite
|
|
|
|
return (FileSystem::FSeek64(fp.get(), current_pos, SEEK_SET) == 0 &&
|
|
|
|
std::fwrite(&entry, sizeof(entry), 1, fp.get()) == 1);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (FileSystem::FSeek64(fp.get(), current_pos, SEEK_SET) != 0)
|
|
|
|
return false;
|
|
|
|
|
|
|
|
// append it.
|
|
|
|
return (std::fwrite(&entry, sizeof(entry), 1, fp.get()) == 1);
|
|
|
|
}
|